做视频播放器这类项目,好多人的第一反应是去GitHub找个完整的开源项目拉下来直接跑,但真正动手就会发现:要么代码版本太老跑不起来,要么结构复杂到改一个按钮都要翻半天,要么干脆就是个半成品。与其花时间在别人的代码里打转,不如自己把核心逻辑捋一遍,从播放器的选型、页面搭建、生命周期管理到手势交互,一步步搭出自己的播放器。这篇文章就围绕一个用Java写的Android视频播放器项目,讲讲我实际开发中的思路、踩过的坑,以及可以直接照抄的代码结构。
我默认看这篇文章的你是:已经能独立写一个简单App,知道Activity和布局文件是怎么一回事,但还不太清楚视频播放器到底该怎么组织代码。如果你是新手,这篇文章的代码可以直接抄;如果你有几年代码经验,里面关于生命周期和播放器封装的思路也值得看一看。
1. 先拆需求:一个视频播放器到底要做什么
动手写代码之前,我习惯先把需求列成清单,要不然写着写着就容易跑偏。对于一个常规的视频播放器项目,核心需求拆开其实就这么几块:
- 能播本地文件和网络流,常见格式如MP4、MKV、AVI都要兼容。
- 有基本的控制栏:播放/暂停、进度条、时间显示、全屏按钮。
- 全屏播放时支持手势控制:左右滑动调进度,上下滑动调音量或亮度。
- 播放器要能适应屏幕方向变化,横竖屏切换时不重新加载视频。
- 后台播放或者切出去再回来,视频能恢复到原来的进度。
你注意一下,我这里没有列“做一个炫酷的播放器外观”,也没有列“支持弹幕、倍速播放”。这些属于加分项,不是核心项。先把基础功能做扎实,再谈别的。很多同学一上来就想着集成这个SDK集成那个SDK,结果业务逻辑全乱掉了。这个项目的主线就一条:用最可靠的方式把视频流畅地播出来,再把控制体验做好。
这个项目我建议用Java而不是Kotlin来写,不是因为Kotlin不好,而是因为Java的教程和参考资料最多,很多播放器相关的源码都是Java写的,你在调试遇到问题时,搜Java的解决方案往往比搜Kotlin的更快。再说了,Java代码和Android系统的底层API更贴近,理解起来更直观。
技术选型上,播放器内核可以用系统自带的MediaPlayer,也可以用谷歌的ExoPlayer。我的建议是:如果你打算做一个长期维护、功能持续增加的播放器,直接用ExoPlayer;如果你只是做一个毕业设计或者小Demo,MediaPlayer就够了。下面的内容我会先用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还是自定义SurfaceView
Android自带一个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.0'9.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,还不行就提示用户用系统播放器打开。有了这一层兜底,你的播放器覆盖率会高很多。