简介:一份面向安卓开发者的多功能视频播放器完整源码工程,集成ijk、系统与exo三种可切换的播放内核,支持多种网络协议,实现了边播边缓存、数十种滤镜、水印、GIF截图、弹幕、外挂字幕、列表播放、重力旋转与手动旋转同步、多分辨率切换等丰富特性,适合安卓音视频工程师、播放器组件二次开发人员以及开源框架研读者参考。压缩包共五百六十二个文件,容量七十五点五兆,主体包括两百六十八个Java源文件、一百二十七个XML布局与配置文件、三十个SO动态解码库,并附有Gradle构建脚本、工程说明文档、演示用图片动图及示例音视频,便于在集成开发环境中直接导入与分析。已有四千六百九十九人学习,资源质量及功能性经过较多用户验证。借助该工程可梳理播放器内核适配层、边播边缓存策略、滤镜渲染管线与列表无缝切换等关键模块的代码实现,同时可参考其弹幕、广告、小窗、进度条预览等功能的组织方式,为后续商业项目扩展或源码级二次开发提供扎实基础。
1. GSYVideoPlayer 到底替你干了哪些活
做过 Android 播放器的同学都清楚,系统自带 MediaPlayer 只适合 demo 级别的播放需求,一旦涉及 HTTPS、rtsp、边播边缓存、列表复用、全屏动画这些词,就要自己造轮子。GSYVideoPlayer 在 GitHub 上被大量项目引用,是因为它把 IJKplayer、ExoPlayer、MediaPlayer 三套播放内核统一成一套对外 API,再从这套 API 往上补了弹幕、外挂字幕、滤镜、水印、GIF 截图、广告插播、多实例播放、重力旋转同步、进度条帧预览等一整套播放器周边能力。与其说它是一个播放器,不如说它是一个「已经帮你踩过一遍坑的播放器框架」。这篇文章会从内核选型开始,把网络层、渲染层、列表交互和 rtsp 接入这几个最容易出问题的地方逐个拆开讲。
2. IJKplayer、ExoPlayer、MediaPlayer 三内核怎么选、怎么切
2.1 IJKplayer:协议最全,但维护成本摆在那
IJKplayer 基于 FFmpeg,B 站开源之后一直是国内直播、点播 App 的首选内核,支持 rtsp、rtmp、mms 这类 ExoPlayer 很难搞定的协议。社区里常听到 ijkplayer 6.1.1 这个版本号,它对应的 FFmpeg 内核已经足够新,硬解走 MediaCodec,软解走 FFmpeg,遇到系统解码器不支持的编码格式时能兜底。缺点是 so 体积大,而且原作者已经很久不更新,很多机型的新问题要自己改源码编译。
如果你要在 WSL 下编译 ijkplayer,常见做法是装好 NDK r21e,拉源码后直接跑compile-ijkplayer.sh。真正费时间的是 FFmpeg 的 configure 步骤,尤其是想开 https、rtmp 或者 rtsp over tcp 时,需要在 configure 里手动指定 openssl 和对应的 protocol 开关,默认的--disable-protocol=*会把一大堆协议关掉。编出来的 so 有多个 ABI,实际打包时只保留 arm64-v8a 和 armeabi-v7a 就能省下不少体积。
2.2 ExoPlayer:自适应流和 HTTPS 的主场
ExoPlayer 是 Google 官方维护的播放内核,在 GSYVideoPlayer 里承担的是「现代播放器」角色。它原生支持 HLS、DASH、SmoothStreaming 这类自适应码率协议,带宽变化时自动切分辨率,HTTP 层的扩展性也更好,可以用 OkHttp 替换底层网络栈,处理自签证书、拦截日志、自定义 Header 都比 IJKplayer 顺手。GSYVideoPlayer 的缓存代理层和 ExoPlayer 配合也最顺,因为 ExoPlayer 通过 DataSource 接口访问网络,代理层可以透明插入。
rtsp 在 ExoPlayer 里属于后来才加的协议,支持程度明显不如 IJKplayer;rtmp 基本不用考虑。所以选型口诀是:点播、自适应流、HTTPS 场景优先 ExoPlayer,rtsp、rtmp 直播源优先 IJKplayer。
2.3 MediaPlayer:轻量场景的兜底
系统 MediaPlayer 封装的是系统解码器和底层播放框架,优点是零依赖、不引入 so 文件,适合音频播放、TV 盒子、车机这类对视频增强功能没有要求的场景。缺点是扩展能力几乎为零,不支持边播边缓存代理,在列表复用场景下也没有现成的全屏动画机制,而且不同厂商 ROM 对 MediaPlayer 的底层实现差异很大。GSYVideoPlayer 保留这个内核,主要目的是让项目在不需要复杂视频功能时减少包体。
2.4 用 PlayerFactory 切换 GSYVideoPlayer 的播放内核
GSYVideoPlayer 在不同版本里的切换方式略有差异,常见做法是基于PlayerFactory设置全局播放内核:
// 切到 IJKplayer 内核,适合 rtsp、rtmp、直播源 PlayerFactory.setPlayManager(IjkPlayerManager.class); // 切到 ExoPlayer 内核,适合 HTTPS、HLS、自适应码率 PlayerFactory.setPlayManager(ExoPlayerManager.class); // 切到系统 MediaPlayer PlayerFactory.setPlayManager(SystemPlayerManager.class);这段代码必须在创建播放器之前调用。IjkPlayerManager内部持有IjkMediaPlayer,底层走 FFmpeg 解码链路;ExoPlayerManager包的是SimpleExoPlayer,对外暴露的接口与 IJK 保持一致;SystemPlayerManager则把系统MediaPlayer封装成同一套接口。业务代码不直接操作内核,只面向GSYVideoPlayer暴露的方法,所以内核切换对上层透明。
硬解与纹理渲染的开关也需要在初始化阶段配合设置:
// 优先硬解 GSYVideoType.enableMediaCodec(); // 硬解 + 纹理渲染,滤镜、水印、GIF 截图依赖这个模式 GSYVideoType.enableMediaCodecTexture(); // 回到软解 GSYVideoType.disableMediaCodec();enableMediaCodecTexture()是后面讲滤镜和截图的关键前置条件,它让视频帧先进入 OpenGL 纹理,再由GSYVideoPlayer去做二次处理。enableMediaCodec()只硬解不做纹理处理,适合纯播放场景。要注意同一个播放器实例内切换内核没有意义,内核切换只对之后新建的播放器实例生效。
| 内核 | rtsp/rtmp | HTTPS | HLS/DASH | 边播边缓存 | 滤镜/水印 | 维护成本 |
|---|---|---|---|---|---|---|
| IJKplayer | 好 | 需编译 openssl | 一般 | 支持 | 支持 | 高 |
| ExoPlayer | 弱 | 好 | 好 | 支持 | 支持 | 低 |
| MediaPlayer | 不支持 | 一般 | 部分 | 不支持 | 不支持 | 低 |
实际项目里最常见的做法是默认 IJKplayer,视频源是 HLS 或需要 OkHttp 注入 Header 时动态切 ExoPlayer。切换后要重新setUp播放地址,因为底层播放器实例已经重建,播放进度需要自己保存并在 prepare 完成后 seek 回去。
3. HTTPS、边播边缓存与进度条预览:网络数据层怎么调
3.1 HTTPS:证书校验、明文限制与 OkHttp 拦截
GSYVideoPlayer 本身不决定 HTTPS 通不通,真正起作用的是内核的网络栈。IJKplayer 走 FFmpeg 的 TLS 实现,需要编译时加入 openssl;ExoPlayer 默认走DefaultHttpDataSource,可以替换成 OkHttp 通道。Android 9 之后系统默认禁止明文流量,如果你的视频源是http://地址,需要先处理网络安全配置:
<!-- res/xml/network_security_config.xml --> <network-security-config> <base-config cleartextTrafficPermitted="false" /> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">your-video-server.com</domain> </domain-config> </network-security-config>然后在 Manifest 的 application 节点引用它:
<application android:networkSecurityConfig="@xml/network_security_config" ... />cleartextTrafficPermitted只对域名生效,避免为了一个测试地址把整个应用的明文开关全部打开。release 包如果要支持自签证书,正确做法是把证书打入应用或做证书 pinning,不是在全局信任所有证书。
调试时如果想抓 HTTPS 明文,ExoPlayer 配合 OkHttp 拦截器是最省事的路径:
OkHttpClient client = new OkHttpClient.Builder() .sslSocketFactory(sslContext.getSocketFactory(), trustManager) .addInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) .build(); OkHttpDataSource.Factory factory = new OkHttpDataSource.Factory(client); // 把 factory 设置到 ExoPlayerManager 对应的数据源工厂 exoPlayerManager.setDataSourceFactory(factory);HttpLoggingInterceptor会把请求头和响应体打到日志里,排查 403、鉴权失败、防盗链签名过期这类问题非常快。注意这只适合 debug 环境,release 环境要移除trustManager的信任所有证书逻辑,否则等于关闭了 HTTPS 的证书校验。
3.2 边播边缓存:缓存目录、上限与直播流的例外
GSYVideoPlayer 的边播边缓存核心是 AndroidVideoCache 这套代理方案,它在本地开一个 HTTP 服务,把远程视频地址包装成http://127.0.0.1:port/真实地址,播放器请求本地代理,代理边下载边保存文件。常见初始化方式:
// Application 中初始化缓存服务 HttpProxyCacheServer cacheServer = new HttpProxyCacheServer.Builder(this) .maxCacheSize(1024 * 1024 * 1024L) // 缓存上限 1GB .cacheDirectory(new File(getCacheDir(), "video_cache")) .build(); GSYVideoManager.setProxy(cacheServer);maxCacheSize决定缓存文件达到多大后开始淘汰旧文件;cacheDirectory建议放在应用私有目录,避免用户清理 App 数据后残留大量视频文件。缓存文件名的生成基于 URL 的 Hash,同一 URL 二次播放时直接走本地文件,没有网络请求。
缓存协议只对支持 Range 的 HTTP 源有效。rtsp 直播源没有 Content-Length,代理无法预知文件大小,硬走缓存会把直播流当无限长文件一直写盘,最终磁盘写满,播放延迟也越来越大。所以直播场景必须关闭缓存:
videoPlayer.setUp(url, false, null); // 第二个参数 live 传 false 表示不是直播 videoPlayer.setCacheWithPlay(false); // 明确关闭边播边缓存判断一个地址能不能缓存,最简单的标准是看服务器响应有没有Accept-Ranges和Content-Length头。rtmp、rtsp、大部分 m3u8 切片流都不适合直接套缓存代理。
3.3 视频加载速度:分析时长、probesize 与首屏优化
视频起播慢的常见原因是播放器在解析媒体信息阶段花太多时间。FFmpeg 内核默认会分析一段数据才返回 prepare 完成,rtsp 直播源尤其明显:
IjkMediaPlayer ijkPlayer = (IjkMediaPlayer) player; // 分析时长,单位是微秒,rtsp 直播可以压到 500ms 以内 ijkPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", "500000"); // 探测缓冲区大小,单位字节,默认值较大时会拖慢起播 ijkPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", "1024"); // 关闭额外缓冲,边下边播 ijkPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer");analyzeduration调小后,遇到头部信息复杂的视频文件可能解析失败,所以点播源建议设置在 1 秒到 2 秒。probesize对应分析数据的大小,调小可以加快起播,但过小会导致部分视频的 meta 信息读取不完整。这两组参数不是越大越好,要按视频源类型分别设置。
ExoPlayer 内核则通过LoadControl控制缓冲水位,默认配置偏保守。如果你要追求首屏速度,可以自定义DefaultLoadControl,把bufferForPlaybackMs调到 1500ms 左右,减少首帧前等待的数据量。
3.4 进度条小窗口预览:从帧抽取到暂停预览
进度条小窗口预览有两种实现路线。一种是用MediaMetadataRetriever.getFrameAtTime()抽缩略图,适合列表页 hover 显示,缺点是每次拖动都要解码关键帧,频繁拖动时 CPU 占用高、缩略图延迟明显。另一种是 GSYVideoPlayer 更常用的「暂停预览」方案:手指拖动 SeekBar 时,播放器 seek 到目标位置并暂停,画面停在对应帧;松手后恢复播放:
private long lastPreviewTime; private void previewTo(long targetPosition) { // 防抖:拖动回调频率远高于 seek 能处理的速度 if (System.currentTimeMillis() - lastPreviewTime < 300) { return; } lastPreviewTime = System.currentTimeMillis(); player.seekTo(targetPosition); player.setSpeed(0f); // 部分内核支持 0 速暂停预览 } @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { if (!fromUser || player == null) return; long position = (long) (progress * 1f / 1000 * player.getDuration()); previewTo(position); } @Override public void onStopTrackingTouch(SeekBar seekBar) { player.setSpeed(1f); player.seekTo(currentSeekPosition); player.start(); }setSpeed(0f)不是所有内核都支持,MediaPlayer 内核需要改成player.pause(),IJKplayer 和 ExoPlayer 则可以直接用 0 速。预览画面的小窗口本质上还是同一个播放器的渲染画面,做法是在拖动时把播放器容器从大屏区域移到小窗 FrameLayout 中,或者让 TextureView 的 LayoutParams 从小窗口尺寸变为全屏尺寸。注意 seek 到关键帧间隔较大的视频时,实际显示的画面不一定精确落在目标秒数,这是 GOP 编码结构决定的,不是 bug。
提示:进度条预览频繁触发
onProgressChanged时,CPU 和内存开销都会上升,防抖阈值建议保持在 200ms 到 300ms。如果预览窗口还要显示时间气泡,时间文本的更新频率与防抖保持一致,避免 UI 线程被刷爆。
4. 旋转同步、列表播放、全屏动画与小窗口拖动
4.1 视频自带 rotation 与重力旋转的同步规则
视频文件编码时,采集设备的传感器方向会以 rotation 字段写进轨道信息,常见值是 0、90、180、270。播放器读取这个字段后,在渲染阶段把画面旋转到正确方向。GSYVideoPlayer 里对应的是setRotateViewAuto(true),它让播放器自动处理视频自带的方向。
问题出在同时开启重力感应时。如果设备横屏且视频自带 rotation 90,两个旋转叠加,画面会变成颠倒的。处理规则是:rotation 为 0 和 180 的视频可以跟随系统重力旋转,rotation 为 90 和 270 的视频以视频自带旋转为准,不再叠加系统方向:
videoPlayer.setRotateViewAuto(true); videoPlayer.setRotateWithSystem(true);| 视频 rotation | 是否启用重力旋转 | 最终显示效果 |
|---|---|---|
| 0 | 是 | 跟随设备横竖屏 |
| 180 | 是 | 跟随设备横竖屏 |
| 90 | 否 | 画面自动立起 |
| 270 | 否 | 画面自动立起 |
如果你发现视频横屏播放时比正常方向多转了一圈,多半是setRotateViewAuto和setRotateWithSystem同时生效导致的重复旋转。排查时先关掉重力旋转,只保留setRotateViewAuto,确认画面方向正确后再打开系统旋转;反之亦然。
4.2 列表播放:item 复用、全屏动画与无缝跳详情
RecyclerView 里放多个播放器是高频翻车场景。item 滑出屏幕后,播放器 View 被回收,但播放器内核实例可能还在运行,导致滑回来时画面错乱或者声音还在播。常见做法是让列表只维护一个当前播放实例,item 被回收时统一释放:
@Override public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) { super.onViewRecycled(holder); // 判断回收的是不是当前正在播放的 item if (holder.getAdapterPosition() == currentPlayPosition) { GSYVideoManager.releaseAllVideos(); } }GSYVideoManager.releaseAllVideos()会把内部持有的播放器实例、Surface 和网络资源全部释放。配合GSYVideoManager.setListener监听onAutoCompletion,在当前视频播完后自动切换下一个 item 的播放。注意释放时不要直接把 item 里的 View 设置为 gone,否则 item 复用时会闪黑屏。
列表全屏动画是 GSYVideoPlayer 的强项之一,它把 item 中的小窗口播放器通过坐标变换平滑放大到全屏。相关开关包括:
videoPlayer.setShowFullAnimation(true); // 开启进入全屏的缩放动画 videoPlayer.setLockLand(true); // 强制全屏后横向 videoPlayer.setFullHideAction(GSYVideoPlayer.FULLSCREEN_ENTER_EXIT);setFullHideAction只在全屏动画中使用,它指定全屏页隐藏状态栏和导航栏的时机。动画卡顿通常是因为 item 位置信息获取失败,播放器会退化为直接全屏,没有中间动画。确认列表 item 的父容器不要提前执行setVisibility(GONE),否则无法计算起始坐标。
列表切换详情页无缝播放的核心是保持播放器实例不销毁。点击 item 时把播放器从列表容器中摘出来,交给详情页容器,Activity 跳转完成后播放器继续渲染画面:
ViewGroup parent = (ViewGroup) player.getParent(); if (parent != null) { parent.removeView(player); } detailContainer.addView(player);这里要求播放器必须走 GSYVideoManager 的单实例模式,GSYVideoManager.releaseAllVideos()在跳转瞬间不能调用,否则播放器会直接销毁。切页时暂停播放还是继续播放,取决于产品需求,但暂停时不要调用release,只需要player.onPause()和player.onResume()。
4.3 小窗口拖动、声音亮度调节与调整比例
列表小窗口拖动是 GSYVideoPlayer 对外提供的一套浮动窗口能力。开启小窗口拖动后,播放器 View 可以脱离列表容器,在屏幕内移动,收到触摸事件时自己处理坐标变化:
videoPlayer.setPlayTag("float"); videoPlayer.setSmallVideoTextureView(new TextureView(context));拖动逻辑要区分「点击」和「拖动」,不能一收到ACTION_MOVE就移动播放器。常见做法是用 ViewConfiguration 的getScaledTouchSlop()判断位移阈值,小于阈值视为点击,继续走原本的点按逻辑;超过阈值才进入拖动状态,并修改播放器所在容器的 LayoutParams 坐标。
声音和亮度调节在 GSYVideoPlayer 里默认是手势控制,播放器左侧上下滑动调亮度,右侧上下滑动调音量,横向滑动调进度。这套手势开关是:
videoPlayer.setIsTouchWiget(true); // 开启触摸手势 videoPlayer.setIsTouchWigetFull(false); // 是否仅全屏时响应手势setIsTouchWiget的小写写法是历史遗留命名,很多老项目里就是这么写的,不要改成驼峰,否则会调用不到方法。如果你要在自定义播放器里屏蔽某一种手势,需要重写onTouchEvent并拦截对应方向的事件。
调整比例通过setVideoScaleType控制:
videoPlayer.setVideoScaleType(GSYVideoPlayer.SCREEN_MATCH_DEFAULT); // 等比缩放,可能有黑边 videoPlayer.setVideoScaleType(GSYVideoPlayer.SCREEN_MATCH_FULL); // 拉伸铺满,可能变形 videoPlayer.setVideoScaleType(GSYVideoPlayer.SCREEN_MATCH_16_9); // 强制 16:9实际使用中,竖屏短视频用SCREEN_MATCH_DEFAULT,横屏长视频用SCREEN_MATCH_FULL或按视频宽高比动态计算。多分辨率切换时,先记录当前播放进度,再setUp新的清晰度地址,prepared 后再seekTo到记录位置。切换期间用户看到的是黑屏或加载动画,所以分辨率切换会放在片头广告结束、正片即将播放的时间点。
5. 弹幕、外挂字幕、滤镜与 GIF 截图:播放器之上的渲染能力
5.1 弹幕与外挂字幕:与播放进度保持同步
弹幕不是播放器的功能,而是覆盖在播放器上的一层独立 View。GSYVideoPlayer 提供的绑定接口简化了弹幕层与播放器的联动:
DanmakuView danmakuView = findViewById(R.id.danmakuView); player.setDanmakuView(danmakuView);setDanmakuView会在播放器暂停时暂停弹幕、恢复时继续弹幕、seek 时自动清除当前弹幕并重新定位。如果你不用这个接口,就要自己在播放器的onPause、onResume、onSeekComplete回调里手动调用danmakuView.pause()、danmakuView.start()、danmakuView.seekTo(),很容易漏掉某个状态导致弹幕和视频画面错位。
外挂字幕最常见的做法是解析 srt 文件,然后监听播放进度更新字幕文本:
List<SubtitleItem> items = SrtParser.parse(subtitleFile); player.setOnProgressListener((newProgress, position, duration) -> { SubtitleItem item = SrtParser.find(items, position); subtitleView.setText(item == null ? "" : item.text); });字幕解析需要处理时间戳跨行、逗号还是点号的小数分隔符、以及字幕重叠的情况。position的单位是 millis,srt 文件的时间戳格式是HH:mm:ss,mmm,解析时统一转成 millis 比对。如果还要支持 ass 字幕,就需要额外解析样式标签{\an8}、{\pos(x,y)}这类指令,业务上不追求完全还原 ass 特效的话,建议直接剥掉花括号里的内容,只保留纯文本。
5.2 滤镜与水印:基于 OpenGL 的渲染管线
滤镜的前提是播放器工作在其他视频内核之上,并且开启了纹理渲染模式。没有enableMediaCodecTexture()的情况下,setVideoFilter不会生效。打开纹理渲染后,视频帧被解码进 GPU 纹理,再经过一长串 GPUImage Filter 输出到屏幕:
GSYVideoType.enableMediaCodecTexture(); player.setVideoFilter(new GPUImageBrightnessFilter(0.5f));GPUImageBrightnessFilter只是亮度示例,常见滤镜还包括对比度、饱和度、色彩偏移、模糊、素描效果。滤镜可以叠加,但每多一个 filter 就多一次 fragment shader 执行,低端机上要控制滤镜数量,否则帧率下降明显。
水印本质上也是滤镜。比较通用的做法是自定义一个继承GPUImageTwoInputFilter的 Filter,第二个输入纹理就是水印 Bitmap,在 fragment shader 里把水印纹理通过 alpha 混合叠加到视频纹理上。水印位置需要在 shader 里根据纹理坐标计算,左上角还是右上角取决于坐标系的 origin。设置水印时要注意 Bitmap 不要频繁创建和释放,因为纹理上传 GPU 的带宽有限,每次重建水印纹理都会引起掉帧。
5.3 GIF 截图与片头、中间广告
GIF 截图的核心是从播放器取出连续帧,再编码成 GIF。单帧截图可以直接调播放器接口:
player.saveFrame(new File(getCacheDir(), "frame.png").getAbsolutePath());saveFrame从当前帧所在的纹理或 Surface 中读取像素数据,时机必须是在视频绘制完成之后,调早了拿到的可能是黑屏。GIF 则是连续抓取多帧:
// 常见做法:按固定间隔抓帧,再交给 GIF 编码器 AnimatedGifEncoder encoder = new AnimatedGifEncoder(); encoder.start(outputStream); encoder.setDelay(80); // 每帧间隔 80ms encoder.setRepeat(0); // 无限循环 for (Bitmap frame : frames) { encoder.addFrame(frame); } encoder.finish();GIF 的帧率不要超过 15fps,分辨率也要等比压缩,常见的 3 秒 GIF 在 480x270 下编码出来也就几百 KB。抓帧过程不能放在 UI 线程,因为 bitmap 的像素读取和 PNG/JPEG 编码都是 CPU 密集型操作。
片头广告和中间广告在 GSYVideoPlayer 的示例代码里有现成的GSYADVideoPlayer实现。它的逻辑是先播广告源,广告播放完成后再加载正片:
GSYADVideoPlayer adPlayer = findViewById(R.id.adPlayer); adPlayer.setUp(adUrl, false, null); adPlayer.startPlayLogic(); adPlayer.setOnCompletionListener(mp -> { // 广告结束,切换正片 videoPlayer.setUp(videoUrl, true, cacheKey); videoPlayer.startPlayLogic(); });中间广告的插入位置通常在正片暂停时或某一个进度节点。实现时要记录正片当前播放位置,广告结束后从记录位置继续 seek,不能让用户重新看一遍广告前后的内容。片头广告建议复用同一个播放器容器,避免两个播放器实例抢音频焦点。
5.4 多个同时播放:音频焦点与 SurfaceTexture 上限
GSYVideoPlayer 默认的 GSYVideoManager 是单实例设计,同一时间控制一个播放器,避免多个视频抢占音频焦点和 GL 资源。如果你需要「多个同时播放」,比如双路对比视频,就要绕过 manager 直接创建多个 player 实例,此时有两道硬限制:
| 资源 | 限制说明 |
|---|---|
| SurfaceTexture | 不同机型支持的数量上限不同,一般只有 2 到 4 个 |
| AudioFocus | 只能同时有一个音频输出,多路播放时其他路必须 mute |
SurfaceTexture 超限通常表现为黑屏或不渲染,没有明显的异常日志,需要自行控制实例数量。多路播放时,非主视频需要用player.setVolume(0f, 0f)静音,再配合AudioManager.requestAudioFocus处理来电、闹钟等系统打断场景。不要用setVolume(0, 0)的旧式写法,GSYVideoPlayer 的对外 API 基于 float 音量。
6. rtsp 直播源接入:TCP/UDP 与延迟调优的技巧
6.1 为什么 rtsp 只推荐 IJKplayer
rtsp 拉流走的是实时传输协议,ExoPlayer 对 rtsp 的支持范围还很有限,MediaPlayer 基本不碰这个协议。GSYVideoPlayer 里做 rtsp 直播源调试,几乎都是切到 IJKplayer 内核。如果你没有公网源,可以用 GStreamer 自带的 rtsp server 在本地起一路测试流,推一路摄像头或文件源,再拿 Android 设备拉流验证,这比找不稳定的公网地址要可靠得多。
6.2 rtsp_transport 与延迟参数
rtsp 默认传输方式在不同的服务端实现里不一样,UDP 在丢包严重的 WiFi 环境下会花屏,TCP 则相对稳定。常见做法是强制走 TCP:
IjkMediaPlayer ijkPlayer = (IjkMediaPlayer) player; // 强制 TCP 传输,减少 UDP 丢包导致的马赛克 ijkPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "rtsp_transport", "tcp"); // 让 rtsp 解析时优先选择 TCP ijkPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "rtsp_flags", "prefer_tcp"); // 只拉视频流,音频单独处理时效果更好 ijkPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "allowed_media_types", "video");rtsp_transport取值有udp、tcp、udp_multicast。内网环境 UDP 延迟更低,公网和弱网环境 TCP 更稳定。直播延迟调优时,把analyzeduration和probesize压到最小,再配合:
ijkPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer"); ijkPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "packet-buffering", "0");fflags的nobuffer告诉 FFmpeg 不要额外缓冲,包到达后尽量直接解码显示;packet-buffering=0关闭包缓冲。这两个参数能明显降低延迟,但网速波动时卡顿也会更频繁,属于典型的「用稳定性换低延迟」取舍。
6.3 无缝切台与缓存开关
rtsp 是直播流,没有文件总长度,不能 seek,也不能走边播边缓存。切台时如果调用release再重新setUp,中间会有明显的黑屏和 loading。无缝切台的思路是预加载:
// 当前频道还在播时,提前创建新的播放器实例去 prepare 下一路 IjkMediaPlayer nextPlayer = new IjkMediaPlayer(); nextPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "rtsp_transport", "tcp"); nextPlayer.setDataSource(nextUrl); nextPlayer.prepareAsync(); // 用户点击切台后,把 nextPlayer 的 Surface 绑定到当前播放器的显示层切台瞬间把当前播放器释放,用已经准备好的下一路接管 Surface,视觉上几乎没有卡顿。注意切台后的rtsp_transport要和上一路保持一致,否则播放器重新协商传输方式,预加载效果会被抵消。直播场景统一的配置是setUp(url, false, null)再显式setCacheWithPlay(false),否则缓存代理会把无限长的直播流一直写盘。
本文还有配套的精品资源,点击获取