做视频播放器这类项目好多人的第一反应是去GitHub找个完整的开源项目拉下来直接跑但真正动手就会发现要么代码版本太老跑不起来要么结构复杂到改一个按钮都要翻半天要么干脆就是个半成品。与其花时间在别人的代码里打转不如自己把核心逻辑捋一遍从播放器的选型、页面搭建、生命周期管理到手势交互一步步搭出自己的播放器。这篇文章就围绕一个用Java写的Android视频播放器项目讲讲我实际开发中的思路、踩过的坑以及可以直接照抄的代码结构。我默认看这篇文章的你是已经能独立写一个简单App知道Activity和布局文件是怎么一回事但还不太清楚视频播放器到底该怎么组织代码。如果你是新手这篇文章的代码可以直接抄如果你有几年代码经验里面关于生命周期和播放器封装的思路也值得看一看。1. 先拆需求一个视频播放器到底要做什么动手写代码之前我习惯先把需求列成清单要不然写着写着就容易跑偏。对于一个常规的视频播放器项目核心需求拆开其实就这么几块能播本地文件和网络流常见格式如MP4、MKV、AVI都要兼容。有基本的控制栏播放/暂停、进度条、时间显示、全屏按钮。全屏播放时支持手势控制左右滑动调进度上下滑动调音量或亮度。播放器要能适应屏幕方向变化横竖屏切换时不重新加载视频。后台播放或者切出去再回来视频能恢复到原来的进度。你注意一下我这里没有列“做一个炫酷的播放器外观”也没有列“支持弹幕、倍速播放”。这些属于加分项不是核心项。先把基础功能做扎实再谈别的。很多同学一上来就想着集成这个SDK集成那个SDK结果业务逻辑全乱掉了。这个项目的主线就一条用最可靠的方式把视频流畅地播出来再把控制体验做好。这个项目我建议用Java而不是Kotlin来写不是因为Kotlin不好而是因为Java的教程和参考资料最多很多播放器相关的源码都是Java写的你在调试遇到问题时搜Java的解决方案往往比搜Kotlin的更快。再说了Java代码和Android系统的底层API更贴近理解起来更直观。技术选型上播放器内核可以用系统自带的MediaPlayer也可以用谷歌的ExoPlayer。我的建议是如果你打算做一个长期维护、功能持续增加的播放器直接用ExoPlayer如果你只是做一个毕业设计或者小DemoMediaPlayer就够了。下面的内容我会先用MediaPlayer讲清楚播放器的基础原理然后再讲ExoPlayer的替换方案这样你能从底层理解播放器到底是怎么工作的。2. 播放器内核选型MediaPlayer与ExoPlayer的取舍2.1 MediaPlayer官方自带但能力有限MediaPlayer是Android系统从API 1就有的媒体播放类底层封装了系统级的解码器。它的优点很直接不用引入任何第三方依赖代码写起来也简单一个new、两个set、一个start就完事了。它的缺点也明显支持的格式取决于系统硬件的解码能力说人话就是同一段视频在这台手机上能播换一台手机可能就黑屏无声缓冲策略和网络自适应能力基本为零播网络视频时遇到网络波动你只能看到它卡在那毫无办法它也不支持DASH、HLS这类自适应码率协议。所以MediaPlayer适合的场景是本地视频文件、简单Demo、对直播流没有要求的场景。2.2 ExoPlayer功能强大且高度可定制ExoPlayer是谷歌官方开源的一个媒体播放库它不是基于MediaPlayer封装的而是从零实现的一套播放框架底层用的是Android的MediaCodec接口来做音视频解码。正因为是自定义实现它能把控制权完完全全交给你。举几个ExoPlayer的优势例子支持HLS、DASH、SmoothStreaming这些自适应流媒体协议。可以通过自定义DataSource实现缓存、防盗链、本地代理。提供TrackSelector可以方便地切换音轨、字幕轨。有专门的PlayerView控件封装好了SurfaceView、字幕、控制栏这些UI组件。我在实际项目中用ExoPlayer比较多尤其是涉及到网络播放的时候。但要注意ExoPlayer的API版本迭代很快网上很多示例代码用的是旧版本你照着抄经常会遇到方法找不到编译不过的情况。后面我会给出我实测可用的依赖方式。2.3 解码器与格式支持问题不管用哪种内核最终解码都要靠系统的MediaCodec或者硬件解码芯片来完成。这就牵扯到一个很实际的问题你没法保证所有Android设备都能解所有格式的视频。实操建议是播放前加一个错误回调一旦解码失败弹提示或者切换到软解。ExoPlayer里有一个setMediaCodecSelector的机制可以控制选用硬解还是软解但是这个API比较底层一般开发者用不到。更简单的方法是在初始化播放器时把播放器的Player.Listener里的onPlayerError回调接住如果错误类型是PlaybackException.ERROR_CODE_DECODER_INIT_FAILED就弹一个友好的提示框告诉用户“当前设备不支持该视频格式”而不是让用户面对一个莫名其妙的黑屏。3. 项目结构设计怎么组织代码才不容易乱代码组织这件事我见过太多反面教材了一个Activity动辄两三千行播放逻辑、UI更新、手势处理、网络请求全塞在一起改一个变量恨不得全局搜索三遍。这个项目我建议按功能模块分层每个人各司其职。3.1 推荐的包结构activity/只放Activity负责页面生命周期和整体调度。player/播放器核心封装包括播放器管理类、播放状态回调接口。ui/自定义控件比如手势控制层、播放控制栏。util/工具类比如时间格式化、网络状态判断。listener/全局监听器接口定义。这个分层的好处是万一播不了你只需要查player包里有没有问题UI想改样式不会动到播放逻辑。各干各的互不干扰。3.2 播放入口的接口设计播放记录是由几条记录组成的。创建播放时UI 用 VerticalLayout 组织。Activity 在视频点击时调用。现在展示的代码是服务于 UI 的片段。下面这份代码节选我创建VideoPlayer类它包了一层MediaPlayer这样Activity就不需要直接面对MediaPlayer那堆状态码了。public class VideoPlayer { public interface PlayerCallback { void onPrepared(); void onPlayStateChanged(boolean isPlaying); void onError(String message); void onProgressUpdate(int progress, int duration); } private MediaPlayer mediaPlayer; private PlayerCallback callback; private boolean isPrepared false; private boolean isPausedByUser false; public void init() { mediaPlayer new MediaPlayer(); mediaPlayer.setOnPreparedListener(mp - { isPrepared true; mediaPlayer.start(); if (callback ! null) callback.onPrepared(); }); mediaPlayer.setOnCompletionListener(mp - { isPausedByUser true; if (callback ! null) callback.onPlayStateChanged(false); }); mediaPlayer.setOnErrorListener((mp, what, extra) - { if (callback ! null) callback.onError(播放失败错误码: what); return true; }); } public void setDataSource(String path) throws IOException { mediaPlayer.setDataSource(path); } public void prepareAsync() { mediaPlayer.prepareAsync(); } public void play() { if (!isPrepared) return; mediaPlayer.start(); isPausedByUser false; if (callback ! null) callback.onPlayStateChanged(true); } public void pause() { if (!isPrepared) return; mediaPlayer.pause(); isPausedByUser true; if (callback ! null) callback.onPlayStateChanged(false); } public void seekTo(int position) { if (isPrepared) mediaPlayer.seekTo(position); } public void release() { if (mediaPlayer ! null) { mediaPlayer.release(); mediaPlayer null; } } }注意几个细节prepareAsync()是异步准备耗时操作不能放主线程onError里必须return true否则系统会回调onCompletion造成逻辑混乱seekTo在isPrepared为false时不执行因为状态机不允许。3.3 为什么Activity不直接持有MediaPlayer有一种偷懒写法是直接在Activity里new MediaPlayer()什么都不封装代码更短。但问题在于MediaPlayer的生命周期状态机制比较复杂它内部有Idle、Initialized、Preparing、Prepared、Started、Paused、PlaybackCompleted、Error、End这些状态。一旦你没有按照状态机的规律去调用方法比如在Error状态直接start()它甚至会抛IllegalStateException导致崩溃。封装一层之后所有对MediaPlayer的调用都经过VideoPlayer这个门面Activity和Fragment拿到的接口简单明了就算内部状态机再复杂外部也不会被牵连。这也是为什么我强调视频播放器要单独抽一层管理者类的原因。4. 页面搭建播放器UI与进度更新的关键细节4.1 用VideoView还是自定义SurfaceViewAndroid自带一个VideoView控件它把MediaPlayer、SurfaceView和控制逻辑打包在一起用起来特别省事。我在Demo项目里会用它但它有几个问题控制栏比较简陋样式不改不了不支持同时显示字幕布局中对视频尺寸比例的处理很反直觉。所以我做这个项目时就放弃了VideoView改用TextureView加MediaPlayer的组合。TextureView可以把视频帧当作普通的View来处理可以做旋转、缩放、透明度动画还能被放在自定义的控制层下面灵活度比VideoView高得多。如果API 14以上的设备都能用TextureView唯一需要注意的是它内部没有独立的Surface性能上会比SurfaceView略低一点但做普通播放器完全没差别。4.2 布局文件的组织方式播放页面布局我建议用一个FrameLayout做容器底层放TextureView上层叠控制栏和手势层。这样的层级关系是最底层TextureView负责渲染视频画面。中间层控制栏布局包含返回按钮、播放暂停按钮、进度的SeekBar、时间TextView、全屏按钮。最上层手势处理层用于接收滑动手势并显示亮度/音量/进度浮层。用FrameLayout的原因在于视频画面和控制UI天然就是叠加关系LinearLayout做不到这种效果。控制栏做一个半透明渐变背景滑入滑出通过动画实现体验比较顺手。进度更新我用的方案是Handler加Runnable循环private Handler handler new Handler(Looper.getMainLooper()); private Runnable progressRunnable new Runnable() { Override public void run() { if (mediaPlayer ! null mediaPlayer.isPlaying()) { int currentPosition mediaPlayer.getCurrentPosition(); int duration mediaPlayer.getDuration(); seekBar.setProgress(currentPosition); seekBar.setMax(duration); currentTimeTextView.setText(formatTime(currentPosition)); totalTimeTextView.setText(formatTime(duration)); } handler.postDelayed(this, 500); } };我故意把回调间隔设成500毫秒而不是100毫秒一是减少主线程负担二是UI更新频率太高也没必要精确到0.5秒足够用户感知了。如果你做的是音乐播放器进度条要显示当前秒这个频率也够了。4.3 视频尺寸自适应视频画面比例不固定横屏竖屏、宽屏窄屏都有你不能直接让TextureView充满整个屏幕会拉伸变形。正确做法是根据视频的实际宽高比来动态调整TextureView的宽高。我封装了一个自适应方法在回调里拿到视频宽高后计算private void adjustVideoSize(int videoWidth, int videoHeight) { if (videoWidth 0 || videoHeight 0) return; float ratio videoWidth * 1.0f / videoHeight; FrameLayout.LayoutParams params (FrameLayout.LayoutParams) textureView.getLayoutParams(); int screenWidth getResources().getDisplayMetrics().widthPixels; if (ratio 1) { params.width screenWidth; params.height (int) (screenWidth / ratio); } else { params.height screenWidth; params.width (int) (screenWidth * ratio); } textureView.setLayoutParams(params); }注意这里的计算方式它保证视频至少有一边铺满屏幕另一边按比例缩放而不会出现黑边。这种处理策略在竖屏播放横屏视频的时候非常管用视频中间区域铺满用户不需要把手机转过来也能看清内容。5. 生命周期管理播放器崩溃的头号元凶5.1 Activity生命周期与播放状态的同步视频播放器崩溃排行第一的原因就是生命周期没处理。常见的场景是用户正在看视频按了Home键切到后台Activity走了onStop但播放器还在运行然后系统资源不够或者Surface被销毁Activity回到前台时直接白屏。标准的生命周期绑定策略是onStart或onResume里如果之前被暂停了让播放器继续播放。onPause里暂停播放同时停止进度更新循环。onDestroy里释放播放器释放TextureView。onSaveInstanceState里保存当前播放位置和视频路径以便Activity被系统重建后恢复。为什么要在onPause而不是onStop里暂停播放因为onPause之后用户还能看到界面的最后一帧如果立即停掉会有一个短暂的画面定格用户体感上会觉得“卡了一下”而onStop时界面已经不可见了再去停就晚了。当然如果你的播放器支持后台播放那这两个回调的逻辑要反过来处理但普通场景一律按onPause暂停来写。5.2 onSaveInstanceState的处理屏幕旋转是Activity生命周期的一次完整销毁重建如果你不做状态保存转个屏视频就从0开始播很烦。我的做法是private int savedPosition -1; private String savedVideoUrl; Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mediaPlayer ! null) { savedPosition mediaPlayer.getCurrentPosition(); } outState.putInt(position, savedPosition); outState.putString(video_url, savedVideoUrl); } Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); if (savedInstanceState ! null) { savedPosition savedInstanceState.getInt(position, -1); savedVideoUrl savedInstanceState.getString(video_url); } }然后在你初始化播放器时等到onPrepared回调里判断if (savedPosition 0) seekTo(savedPosition)。这里有个坑seekTo必须要在prepare完成之后调用否则会失败。所以一定要把恢复进度放到onPrepared回调里执行不能放在create阶段直接调用。5.3 播放器的多线程更新问题MediaPlayer的内部会通过Handler往主线程发送消息比如onPrepared、onCompletion。这些回调都在主线程执行所以不要在里面做耗时操作否则会阻塞UI。反过来进度更新如果用子线程去跑就不需要每次都post到主线程不对UI的更新只能在主线程做所以还是规规矩矩用主线程Handler更省事。我见过一个有趣的bug开发者用一个子线程循环去轮询getCurrentPosition()并更新SeekBar进度结果视频播到一半SeekBar卡住不动了。原因是getCurrentPosition()本身是一个跨进程调用底层要向MediaPlayer服务发消息短时间内高频轮询反而会阻塞播放器的内部消息队列。所以进度轮询频率确实不能设得太高。6. 触摸事件与手势控制实现6.1 手势识别的基本思路播放器全屏之后用户需要靠手势来控制音量、亮度和进度。这一块用系统提供的GestureDetector配合自定义的onTouchEvent来做。手势逻辑通常分三块单击显示或隐藏控制栏。双击切换播放暂停。滑动在屏幕左侧上下滑调亮度右侧上下滑调音量左右滑调进度。实现时要特别注意滑动距离和实际调节量的映射关系。我的经验值是屏幕高度的1/3对应亮度从0到255的变化音量直接调AudioManager就不需要自己做映射了进度滑动则按屏幕宽度的1/2对应整个视频时长的比例来算。6.2 防止手势冲突在视频播放器里最常见的手势冲突是SeekBar和左右滑动调进度。用户拖动进度条时TouchEvent会被SeekBar自己消费掉这没问题但如果监听器的优先级设置不对用户本来在拖SeekBar系统却以为在滑动屏幕调进度两边一起动画面就会乱。我的处理方式是让根布局先拦截触摸事件判断按下的位置是否在SeekBar区域内如果在就把事件交给SeekBar处理不再做全局手势识别。这个判断你可以自己写也可以直接让手势识别层不覆盖SeekBar区域。6.3 音量与亮度调节细节调节音量我用的是AudioManager audioManager (AudioManager) getSystemService(AUDIO_SERVICE); int maxVolume audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); int currentVolume audioManager.getStreamVolume(AudioManager.STREAM_MUSIC); float ratio slideDistance / screenHeight * maxVolume; audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (int) (currentVolume ratio), 0);调节亮度是用WindowManager.LayoutParams改整个窗口的screenBrightness属性WindowManager.LayoutParams layoutParams getWindow().getAttributes(); layoutParams.screenBrightness brightness; getWindow().setAttributes(layoutParams);这里有个坑直接设置screenBrightness只对当前Activity生效如果你希望用户设置的值永久保留要写进SharedPreferences并在Activity创建时读取。而且不要尝试改系统全局亮度普通App没有这个权限。7. 播放列表与多视频切换7.1 列表播放的数据结构设计如果你打算做的是一个多集连续播放的视频应用播放列表的数据结构就很重要了。我习惯定义一个简单的实体类public class VideoItem { private String videoId; private String title; private String videoUrl; private String thumbnailUrl; private long duration; // getter / setter 省略 }然后是管理播放队列的类核心就两个方法下一个和上一个。在onCompletion回调里自动播下一集是常规操作。7.2 无缝切换下一集切换视频的完整流程是先释放当前播放器再重置状态再setDataSource新地址然后prepareAsync。这也就是RecyclerView点击视频之后的操作路径。做列表循环播放的时候要注意用户主动点列表切换和视频播放完成自动切换这两者的UI状态更新点不一样。前者要更新当前选中下标并刷新列表高亮后者不需要刷新列表只需要更新标题栏就行了。7.3 继续播放记录的保存用SharedPreferences来保存最后播放的位置private static final String PREF_NAME video_pref; private static final String KEY_LAST_URL last_video_url; private static final String KEY_LAST_POSITION last_position; public static void savePlayPosition(Context context, String url, int position) { SharedPreferences sp context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); sp.edit().putString(KEY_LAST_URL, url) .putInt(KEY_LAST_POSITION, position) .apply(); }这个功能非常实用用户在首页看到历史播放记录点进来直接从上次位置继续播体验会好很多。8. 常见问题与排查技巧实录8.1 黑屏、有声音没画面黑屏有声音通常意味着解码器正常工作但画面渲染失败。排查顺序是先确认TextureView是否成功拿到Surface再检查setSurfaceTextureListener里的onSurfaceTextureAvailable是否被回调。很多时候是因为TextureView还没有初始化好就执行了setSurface解决办法是把播放器的prepareAsync放到TextureView的onSurfaceTextureAvailable回调之后调用。另一种黑屏原因是硬解不支持比如一些国产设备对特定编码格式的硬解支持比较差。排查时可以看logcat里MediaCodec相关报错或者干脆把编码格式换成H.264重封装的视频。如果是纯软解场景MediaPlayer不支持直接指定软解就得走ExoPlayer了。8.2 播放卡顿和缓冲问题本地视频如果卡顿多半是解码性能不够或者视频码率太高。如果你在开发机上测试不卡但用户手机上卡八成是分辨率和码率超过了设备硬件解码能力。正常的思路是服务端做多码率版本播放器根据网络情况自动选择。网络视频卡顿MediaPlayer基本无解也就是我前面说的换ExoPlayer。ExoPlayer的DefaultLoadControl可以配置缓冲策略比如setBufferForPlaybackMs和setBufferForPlaybackAfterRebufferMs。我推荐的参数是首次缓冲500ms重新缓冲2000ms这样在网络波动时重播不会太频繁。8.3 释放后崩溃Caused by IllegalStateException这个错误是播放器状态机使用不规范最常见的表现。典型场景是Activity销毁时调用了mediaPlayer.release()但有个异步回调还没来得及执行回调里又调用了mediaPlayer.start()导致空指针或异常状态。我的做法是在release之前把所有的Listener设为null并设置一个isReleased标志位回调里先判断这个标志再执行后续操作。public void release() { isReleased true; if (mediaPlayer ! null) { mediaPlayer.setOnCompletionListener(null); mediaPlayer.setOnErrorListener(null); mediaPlayer.setOnPreparedListener(null); mediaPlayer.release(); mediaPlayer null; } }8.4 Surface销毁导致花屏主页布局不用了或者用户切到后台TextureView对应的Surface可能被系统销毁再回到前台时画面会花屏或者直接变黑。解决办法是在onResume里重新检测Surface是否有效如果Surface已经被销毁就要重新创建TextureView并重新绑定播放器。还有一种省事的做法是使用SurfaceView它在Surface销毁时会有系统级的管理但SurfaceView不能用动画也不能随意旋转灵活性不如TextureView只能根据你的场景取舍。9. ExoPlayer替换方案9.1 引入依赖前面的方案你理解了MediaPlayer的原理之后再来接触ExoPlayer就会很轻松因为概念是相通的播放状态、准备状态、Seek逻辑都差不多只是API名字不同。Gradle配置时要注意ExoPlayer的包名已经统一了新的版本号也用对了implementation androidx.media3:media3-exoplayer:1.2.0 implementation androidx.media3:media3-exoplayer-dash:1.2.0 implementation androidx.media3:media3-exoplayer-hls:1.2.0 implementation androidx.media3:media3-ui:1.2.09.2 ExoPlayer初始化ExoPlayer exoPlayer new ExoPlayer.Builder(context) .setLoadControl(new DefaultLoadControl()) .build(); MediaItem mediaItem MediaItem.fromUri(videoUrl); exoPlayer.setMediaItem(mediaItem); exoPlayer.prepare(); exoPlayer.play();如果你连的是加密的HLS流还需要设置DefaultHttpDataSource的请求头这个就不详细展开了。9.3 PlayerView的使用ExoPlayer官方的PlayerView控件内置了控制栏、进度条、播放按钮甚至支持字幕显示。如果你用PlayerView连自定义控制层都省了。但在定制UI设计时PlayerView的默认样式往往不符合产品需求这时候你就可以像前面MediaPlayer方案一样底下放SurfaceView上面叠自己做的控制栏播放器核心还是ExoPlayer。Media3里对应的渲染View叫PlayerView你可以在它上面加自定义布局。10. 性能优化与代码质量的经验10.1 减少过度绘制播放器页面层级很多底层的TextureView、中层的控制栏、上层的浮层提示。如果布局写得不好三层叠在一起每一帧都要把三层的内容都绘制一遍特别浪费性能。减少过度绘制的方法是把控制栏背景做成半透明而不是完全透明把不需要显示的View用View.GONE而不是View.INVISIBLE。另外一个常见问题全屏和竖屏状态切换时控制栏的布局参数反复重新创建对象很容易造成内存抖动建议把两种状态下的布局参数提前创建好。10.2 使用ViewHolder和复用如果你播放列表页面是用RecyclerView每一行的视频缩略图加载和绑定很考验代码质量。图片加载你可以用第三方库但切记不要在onBindViewHolder里直接加载大图一定压缩完再显示。缩略图这个东西看起来不起眼内存占用其实很大一张1920x1080的图片加载到列表里就是好几MB内存。10.3 播放器的内存泄漏问题内存泄漏在播放器场景下几乎一定会遇到。最常见的问题是进度更新的Handler在Activity销毁后还在往主线程post消息Activity被持有泄漏。解决方案有两种要么在onDestroy里移除所有回调和消息要么把Runnable封装成静态内部类并持有弱引用。我用的是第一种代码直观排查也方便。Override protected void onDestroy() { super.onDestroy(); handler.removeCallbacksAndMessages(null); if (videoPlayer ! null) videoPlayer.release(); }11. 最后的实践经验分享视频播放器这个项目的坑确实比普通App多因为它的工作链路特别长数据源读取、网络请求、缓冲、解码、渲染、音频焦点、生命周期变化、UI交互任何一个环节出问题都会表现为“视频播不了”或者“画面卡住了”。我自己的经验是在写播放器的时候每一个可能出错的环节都要预留好回调出口别图省事使用同步逻辑。播放器这种东西用户操作和系统请求随时可能并发发生不规范的状态管理就一定会崩溃。调试的时候可以多加一些日志尤其是getCurrentPosition()、getDuration()、isPlaying()这几个值的变化过程他们能帮你定位很多问题。看一眼日志你就知道是卡在缓冲阶段还是已经进入播放状态但画面渲染失败还是生命周期把播放器杀掉了。这篇文章所有的代码片段都是从实际项目中抽出来的可以直接用。你照着搭一个基础播放器跑通流程之后再考虑加弹幕、倍速、手势快进这些进阶功能一步一步来就不会把项目改乱了。我最后再分享一个小技巧很多同学在开发视频播放器时都会遇到“在别的手机上能播在自己手机上就播不了”的情况。这种问题一通排查往往是无解的因为设备解码能力和底层实现区别太大。真正稳的方案是给播放器内核加上自动降级机制先用ExoPlayer播放遇到解码错误自动切到MediaPlayer还不行就提示用户用系统播放器打开。有了这一层兜底你的播放器覆盖率会高很多。