ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零封装一个轻量级Android视频播放器:MediaPlayer+SurfaceView实战

从零封装一个轻量级Android视频播放器:MediaPlayer+SurfaceView实战 1. 为什么我要自己写一个 Android 视频播放器做过 Android 音视频开发的人大概都有这种体会系统自带的MediaPlayer和VideoView看着简单真到项目里用起来处处是坑。格式兼容性差、seek 不准、切换视频黑屏、生命周期一乱就崩尤其是产品经理拿着一个从某些渠道导出的特殊编码视频过来说这个播不了你就得加班。而市面上成熟的播放器方案要么依赖体积庞大的第三方库要么配置复杂、文档稀碎想快速集成到一个小项目里反而成了负担。我这个项目就是在这种背景下攒出来的——一个最简单的 Android AVPlayer目标很明确用尽量少的代码把能播、好播、稳定播这件事做扎实。它基于 Android 原生的MediaPlayer加SurfaceView组合封装不引入任何重型依赖核心代码几百行覆盖本地视频、网络流、进度控制、状态回调这些日常最常用的能力。说白了它不是要跟专业播放器 SDK 拼功能而是给那些我就想安安静静播个视频的开发者一个干净、可控、能直接抄的起点。适合谁看如果你是刚接触 Android 音视频的新手想搞明白一个播放器到底由哪几块拼起来或者你是做工具类、教育类 App 的开发者需要一个轻量播放内核自己二次开发再或者你被第三方库的兼容问题折磨过想回到原生方案重新掌控一切——那这篇内容应该能帮到你。下面我会把整个设计思路、核心实现、踩过的坑和排查技巧都摊开讲代码可以直接拿去改。2. 整体架构设计与技术选型思路2.1 为什么是 MediaPlayer SurfaceView 这套组合Android 上做视频播放主流路线其实就那么几条MediaPlayer、ExoPlayer、IJKPlayer再往上就是各种商业 SDK。选型的时候我主要看三个维度——体积、可控性、学习成本。ExoPlayer功能确实强支持 DASH、HLS、平滑流扩展性好但它的依赖包不小API 也相对复杂对于只播个 MP4的场景属于杀鸡用牛刀。IJKPlayer基于 FFmpeg格式兼容性一流但需要编译 so 库集成成本高包体积直接涨好几兆。而MediaPlayer是系统内置的零依赖、零体积增加API 简单直白配合SurfaceView做画面渲染对于绝大多数常见格式H.264/H.265 的 MP4、部分 MKV、3GP 等完全够用。提示MediaPlayer的格式支持取决于设备本身的解码能力不同厂商 ROM 会有差异。如果你的项目要覆盖大量冷门格式还是老老实实上 FFmpeg 系方案。SurfaceView的选择也有讲究。它和普通的View最大区别在于SurfaceView拥有独立的绘图层渲染工作可以交给单独的线程处理不会阻塞主线程 UI。视频这种高频刷新的场景用SurfaceView比TextureView更省电、性能更好。代价是它不支持变换动画旋转、缩放这类 View 动画效果层级控制也麻烦一点。但对于一个播放器来说画面稳定流畅比花哨的动画重要得多所以SurfaceView是更务实的选择。2.2 播放器的状态机设计一个靠谱的播放器核心不是能播而是状态不乱。MediaPlayer内部有一套严格的状态机调用顺序错了直接抛IllegalStateException。我见过太多项目因为没管好状态出现快速切换视频崩溃退出页面还在后台响这类问题。所以我在封装的时候第一件事就是把状态理清楚。MediaPlayer的关键状态包括Idle空闲、Initialized已初始化、Prepared准备完成、Started播放中、Paused暂停、Stopped停止、Completed播放完成、Error错误、End释放。这些状态之间的合法转换路径是固定的比如你必须在Prepared之后才能start()在Initialized之后才能prepareAsync()。我的做法是在外层维护一个自己的状态枚举和MediaPlayer的状态做映射所有对外暴露的操作play、pause、seek、release都先检查当前状态是否允许不允许就直接忽略或回调错误而不是硬着头皮往下调。这样即使上层调用逻辑写得再乱播放器本身也不会崩。2.3 模块划分与职责边界整个播放器我拆成了三块渲染层、控制层、回调层。渲染层就是SurfaceView加它的SurfaceHolder负责把解码后的画面显示出来同时监听 Surface 的创建、销毁、尺寸变化。控制层是核心封装MediaPlayer的创建、数据源设置、准备、播放控制、进度查询。回调层通过接口把准备完成播放结束出错缓冲更新这些事件抛给业务方让 UI 层能做出响应。这样拆的好处是职责清晰。比如你想把SurfaceView换成TextureView只需要动渲染层想加一个自定义的缓冲策略只改控制层业务方想监听什么事件实现回调接口就行。三块之间通过明确的接口通信耦合度低改起来不牵一发动全身。3. 核心实现细节与关键代码拆解3.1 初始化与数据源设置的正确姿势MediaPlayer的创建有两种方式new MediaPlayer()和MediaPlayer.create(context, uri)。后者是静态工厂方法内部帮你做了setDataSource和prepare用起来省事但灵活性差——你没法在 prepare 之前设置一些参数也没法用异步准备。所以我用的是new MediaPlayer()手动走流程。数据源设置是第一个容易出问题的地方。setDataSource有多个重载接收String路径、Uri、FileDescriptor等。这里有个关键点网络地址和本地路径的处理方式不同。本地文件可以直接传路径网络流则必须传完整的 URL而且要在prepareAsync之前设置好。如果是content://这类 Uri得通过ContentResolver拿到FileDescriptor再设置否则可能因为权限问题失败。private void setDataSourceInternal(String path) throws IOException { if (path.startsWith(http://) || path.startsWith(https://) || path.startsWith(rtsp://)) { mediaPlayer.setDataSource(path); } else if (path.startsWith(content://)) { AssetFileDescriptor afd context.getContentResolver() .openAssetFileDescriptor(Uri.parse(path), r); mediaPlayer.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); afd.close(); } else { mediaPlayer.setDataSource(path); } }注意content://类型的 Uri 一定要记得关闭AssetFileDescriptor否则会泄漏文件句柄播几个视频之后就可能打不开新文件了。设置完数据源接下来是prepareAsync()。为什么用异步而不是同步的prepare()因为同步准备会阻塞调用线程如果数据源是网络流可能要等好几秒主线程直接 ANR。异步准备则是在后台线程完成准备就绪后通过OnPreparedListener回调通知。这是播放器不卡 UI 的关键。3.2 SurfaceView 的生命周期与画面绑定SurfaceView的 Surface 不是一开始就存在的它有自己的生命周期surfaceCreated→surfaceChanged→surfaceDestroyed。而MediaPlayer必须在 Surface 存在的时候才能setDisplay或setSurface否则画面出不来。这里有个经典的时序问题如果先prepareAsync完成但 Surface 还没创建setDisplay就会失败或者画面黑屏。反过来如果 Surface 先创建但 MediaPlayer 还没准备好同样绑不上。所以必须两边都就绪才能绑定。我的处理方式是维护两个标志位surfaceReady和playerPrepared。在surfaceCreated里设置surfaceReady true并尝试绑定在OnPreparedListener里设置playerPrepared true并尝试绑定。只有两个都为 true 时才真正执行mediaPlayer.setDisplay(holder)。private void tryBindSurface() { if (surfaceReady playerPrepared mediaPlayer ! null) { mediaPlayer.setDisplay(surfaceHolder); } }surfaceDestroyed的时候要特别小心。这时候 Surface 已经销毁如果 MediaPlayer 还在播放画面会出问题甚至崩溃。正确做法是在surfaceDestroyed里暂停播放并解除绑定等 Surface 重建后再恢复。我一般会记录一个wasPlayingBeforeSurfaceDestroyed标志重建后自动恢复播放状态用户体验更连贯。3.3 播放控制与进度同步播放、暂停、停止这几个操作看似简单但状态检查不能省。比如在Prepared之前调start()会抛异常在Error状态下调pause()也没意义。我在每个控制方法入口都加了状态判断public void start() { if (mediaPlayer ! null currentState State.PREPARED || currentState State.PAUSED || currentState State.PLAYBACK_COMPLETED) { mediaPlayer.start(); currentState State.STARTED; startProgressUpdate(); } }进度同步是另一个重点。MediaPlayer提供了getCurrentPosition()和getDuration()但你不能在主线程里疯狂轮询那样既费电又卡。我的做法是用一个Handler每隔 500 毫秒更新一次进度通过回调抛给 UI 层去刷新进度条。500 毫秒这个间隔是权衡的结果——太短了没必要进度条肉眼也看不出差别太长了拖动体验差。private final Runnable progressTask new Runnable() { Override public void run() { if (mediaPlayer ! null currentState State.STARTED) { int current mediaPlayer.getCurrentPosition(); int duration mediaPlayer.getDuration(); if (callback ! null) { callback.onProgressUpdate(current, duration); } handler.postDelayed(this, 500); } } };拖动进度条seek的时候有个细节seekTo在部分设备上是异步的尤其是网络流。如果你 seek 完立刻读getCurrentPosition拿到的可能还是旧值。稳妥的做法是监听OnSeekCompleteListener在回调里再更新 UI。另外seek 到未缓冲的区域时播放器会先进入缓冲状态这时候 UI 上最好给个 loading 提示不然用户以为卡死了。3.4 音频焦点与后台播放的处理这块是很多新手容易忽略的。你的 App 在播视频突然来电话了或者用户打开了另一个音乐 App这时候你的播放器该怎么办如果不处理音频焦点就会出现两个声音同时响的尴尬场面。正确做法是申请音频焦点。在开始播放前通过AudioManager请求AUDIOFOCUS_GAIN然后监听焦点变化。当焦点被其他应用抢走时AUDIOFOCUS_LOSS暂停播放如果是暂时丢失AUDIOFOCUS_LOSS_TRANSIENT暂停并记录状态等焦点回来再恢复。private final AudioManager.OnAudioFocusChangeListener focusListener focusChange - { switch (focusChange) { case AudioManager.AUDIOFOCUS_LOSS: pause(); abandonAudioFocus(); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: pause(); break; case AudioManager.AUDIOFOCUS_GAIN: start(); break; } };提示申请音频焦点时建议用AUDIOFOCUS_GAIN而不是AUDIOFOCUS_GAIN_TRANSIENT因为视频播放通常持续时间较长属于长期占用场景。至于后台播放取决于你的业务需求。如果是纯视频播放器一般退到后台就暂停如果是音乐类应用需要配合Service和通知栏做前台服务。这个项目里我默认是退到后台暂停把选择权留给业务方。4. 完整实操流程与关键环节落地4.1 从零搭建项目的步骤假设你现在打开 Android Studio想把这个播放器跑起来我按顺序说一遍。首先新建一个 Empty Views Activity 项目minSdk建议设到 21因为MediaPlayer的一些新 API 和SurfaceView的稳定行为在 21 以上才比较可靠。语言选 Java 或 Kotlin 都行我这里用 Java 演示逻辑更直观。第一步在布局文件里放一个SurfaceView和几个控制按钮。布局不用复杂一个占满屏幕的 SurfaceView底部叠一层半透明的控制条放播放/暂停、进度条、时间显示就够了。FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent SurfaceView android:idid/surface_view android:layout_widthmatch_parent android:layout_heightmatch_parent / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_gravitybottom android:background#80000000 android:orientationvertical android:padding8dp SeekBar android:idid/seek_bar android:layout_widthmatch_parent android:layout_heightwrap_content / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal Button android:idid/btn_play android:layout_widthwrap_content android:layout_heightwrap_content android:text播放 / TextView android:idid/tv_time android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:gravityend android:textColor#FFFFFF android:text00:00 / 00:00 / /LinearLayout /LinearLayout /FrameLayout第二步写播放器封装类。我把它命名为SimpleAVPlayer构造时传入Context和SurfaceView。类内部持有MediaPlayer、SurfaceHolder、Handler和状态变量。对外暴露setDataSource、prepare、start、pause、seekTo、release这些方法以及一个回调接口。第三步在 Activity 里初始化播放器设置数据源绑定 Surface 生命周期。这里要注意在onPause里暂停播放在onDestroy里释放播放器避免内存泄漏和后台耗电。4.2 关键参数的选择与计算播放器里有几个参数值得单独说说。缓冲区大小如果你用MediaPlayer播网络流可以通过setBufferSize调整缓冲策略但这个 API 在部分版本上行为不一致我一般不动它用系统默认。进度更新间隔前面说了是 500 毫秒这个值可以根据业务调整比如做精确到帧的编辑器就要更短。视频缩放模式是个绕不开的问题。视频的宽高比和 SurfaceView 的宽高比往往不一致直接显示会拉伸变形。常见的处理有三种拉伸填满会变形、保持比例留黑边letterbox、裁剪填满crop。我默认用保持比例通过计算视频和控件的宽高比来决定缩放矩阵。private void adjustAspectRatio(int videoWidth, int videoHeight) { int viewWidth surfaceView.getWidth(); int viewHeight surfaceView.getHeight(); double videoRatio (double) videoWidth / videoHeight; double viewRatio (double) viewWidth / viewHeight; if (videoRatio viewRatio) { // 视频更宽以宽度为准上下留黑边 int scaledHeight (int) (viewWidth / videoRatio); // 设置 SurfaceView 的布局参数或使用 Matrix 变换 } else { // 视频更高以高度为准左右留黑边 int scaledWidth (int) (viewHeight * videoRatio); } }注意SurfaceView本身不支持 Matrix 变换如果你需要精确控制缩放要么改用TextureView要么在 Surface 上叠加一层自定义 View 做裁剪。这是SurfaceView的一个硬限制选型时要提前想清楚。4.3 播放器释放与资源回收release()是必须调用的否则MediaPlayer持有的解码器、音频设备等资源不会释放播几个视频后就会报无法分配内存或者直接崩溃。释放的顺序也有讲究先停止进度更新再reset()最后release()并置空引用。public void release() { handler.removeCallbacks(progressTask); if (mediaPlayer ! null) { try { mediaPlayer.stop(); } catch (IllegalStateException e) { // 已经停止或未初始化忽略 } mediaPlayer.reset(); mediaPlayer.release(); mediaPlayer null; } currentState State.END; abandonAudioFocus(); }这里stop()外面包了 try-catch因为如果播放器还没 prepare 就调 stop 会抛异常。这种防御性写法在播放器代码里很常见因为状态实在太多与其在每个调用点判断不如在关键操作上兜底。5. 常见问题排查与避坑经验实录5.1 播放失败与错误码解读MediaPlayer出错时会回调OnErrorListener带上what和extra两个参数。what通常是MEDIA_ERROR_UNKNOWN或MEDIA_ERROR_SERVER_DIEDextra则更具体比如MEDIA_ERROR_IO、MEDIA_ERROR_MALFORMED、MEDIA_ERROR_UNSUPPORTED、MEDIA_ERROR_TIMED_OUT。看懂这些码能省很多排查时间。错误码含义常见原因处理方式MEDIA_ERROR_IO文件或网络错误路径错误、网络断开、权限不足检查路径和网络确认存储权限MEDIA_ERROR_MALFORMED文件格式错误文件损坏、编码不支持换文件或转码MEDIA_ERROR_UNSUPPORTED格式不支持设备解码器不支持该编码降级到软解方案MEDIA_ERROR_TIMED_OUT操作超时网络太慢、服务器无响应重试或提示用户我遇到最多的是MEDIA_ERROR_IO十有八九是路径问题。尤其是从相册选视频拿到的content://Uri如果没通过ContentResolver正确打开直接当路径传就会失败。还有一种情况是 Android 10 以上的分区存储访问外部存储需要READ_MEDIA_VIDEO权限没申请就播不了。5.2 黑屏、卡顿与音画不同步黑屏通常有三个原因Surface 没绑定、视频还没 prepare 完、或者视频本身是纯音频。排查的时候先确认setDisplay有没有成功调用再确认OnPrepared有没有回调。如果都正常还是黑屏可能是视频编码设备不支持换个视频试试。卡顿分两种解码卡顿和渲染卡顿。解码卡顿一般是视频码率太高或者分辨率超过设备能力这种只能降码率或换设备。渲染卡顿则可能是主线程被占用检查一下有没有在主线程做耗时操作。音画不同步比较少见MediaPlayer内部有同步机制如果出现通常是音频和视频的时间戳本身有问题属于源文件问题。5.3 快速切换视频导致的崩溃这是实战中最容易踩的坑。用户在列表里快速点不同的视频上一个还没 prepare 完下一个就来了。如果直接release再new很容易在回调里操作已经释放的对象导致NullPointerException或IllegalStateException。我的解决方案是给每次播放请求分配一个自增的token回调里先检查 token 是否匹配当前请求不匹配就忽略。这样即使旧的回调延迟到达也不会影响新的播放。private int currentToken 0; public void play(String path) { final int token currentToken; releasePlayer(); mediaPlayer new MediaPlayer(); mediaPlayer.setOnPreparedListener(mp - { if (token ! currentToken) { mp.release(); return; } // 正常处理 }); // ... }提示这个 token 机制在处理异步回调时非常有用不限于播放器任何请求-回调可能乱序的场景都适用。5.4 内存泄漏与生命周期陷阱播放器持有Context引用如果用的是 Activity 的 Context 且没释放Activity 就回收不了。所以我在封装类里统一用ApplicationContext除非确实需要 Activity 的 Context 做 UI 相关操作。另外Handler如果用了非静态内部类也会隐式持有外部类引用我改成静态内部类加弱引用或者直接用Handler(Looper.getMainLooper())配合显式的移除回调。还有一个隐蔽的坑SurfaceHolder.Callback注册后如果没反注册SurfaceView 销毁时可能还在回调。虽然大多数情况不会出问题但在复杂页面里最好在onDestroy里把回调清干净。6. 功能扩展与二次开发建议6.1 加一个自定义控制条原生播放器没有 UI控制条得自己画。我建议把控制条做成一个独立的自定义 View通过接口和播放器通信而不是把按钮逻辑塞进 Activity。这样控制条可以复用到不同页面也方便做主题切换。控制条的核心是进度条的双向同步用户拖动时暂停进度更新松手后 seek 并恢复更新避免拖动过程中进度条被回调抢回去。6.2 支持倍速播放与字幕倍速播放用PlaybackParams就能实现API 23 以上可用。设置setPlaybackParams(new PlaybackParams().setSpeed(1.5f))即可但要注意部分设备对非 1.0 倍速的音频处理有问题可能出现变调。字幕的话MediaPlayer本身不支持外挂字幕需要自己解析 SRT/ASS 文件然后叠加一层 TextView 或自定义 View 来渲染时间轴和播放进度对齐。6.3 从 MediaPlayer 迁移到 ExoPlayer 的时机什么时候该换方案我的判断标准是当你需要 HLS/DASH 自适应流、需要 DRM 版权保护、需要精确的帧级控制、或者目标设备格式兼容性要求极高时就该考虑ExoPlayer了。迁移的时候把控制层替换掉渲染层和回调层的接口保持不变业务代码基本不用动。这也是我一开始做分层设计的原因——给未来留条后路。我在实际项目里用这套方案播过本地 MP4、网络 MP4、RTSP 流稳定性都还不错。踩过的坑主要集中在 Surface 生命周期和快速切换这两块上面给的 token 机制和双标志位绑定基本能解决。最后分享一个小技巧调试播放器的时候把MediaPlayer的setOnInfoListener也加上它会回调缓冲开始、缓冲结束、视频尺寸变化这些信息排查问题时比只看错误回调有用得多。
返回列表