Android视频播放器选型与GSYVideoPlayer集成实践:内核切换与RTSP/HTTPS优化
简介一套基于系统MediaPlayer与ExoPlayer2实现的GSYVideoPlayer多功能视频播放器Android源码面向需要集成或定制播放器的客户端应用开发人员。播放器内核支持IJKPlayer、ExoPlayer、MediaPlayer三种解码器自由切换同时覆盖边播边缓存、Https/RTSP/HLS/RTMP等协议、20余种实时滤镜、视频截图与GIF生成、水印与片头/中间广告、外挂字幕与弹幕、列表播放与全屏动画、多分辨率切换、进度条小窗口预览以及重力旋转与手动旋转同步等大量实用能力均可直接用于二次开发。整个资源包共562个文件约75.5MB以268个Java源码和127个XML布局/配置为主辅以30个so动态库、gradle构建脚本、演示用音视频与说明文档便于从工程结构到播放器逻辑逐层拆解。已有4699人学习下载适合具备一定Android基础、希望深入研究播放器底层原理与UI交互细节的开发者参考学习。1. 为什么播放器选型最终都绕不开 GSYVideoPlayer做 Android 音视频的团队几乎都会遇到同一个问题IJKplayer、ExoPlayer、MediaPlayer 各有所长项目却不可能为每个需求换一套顶层 API。GSYVideoPlayer 把三套内核统一封装用枚举切换同时把 HTTPS、RTSP、弹幕、外挂字幕、滤镜、水印、gif 截图、广告插入、边播边缓存这些高频能力塞进同一套 View 体系。它不是简单的“包一层”而是连同列表复用、全屏动画、重力旋转、小窗口拖动这些交互一起处理了。下面按“选内核 - 接功能 - 处理列表与旋转 - 调 RTSP 和缓存 - 自己编译底层”的顺序把可复现的代码和参数写出来直接搬进项目就能少踩一轮坑。2. 播放内核选型与 HTTPS 配置IJKplayer、ExoPlayer、MediaPlayer2.1 三套内核的能力边界用 GSYVideoPlayer 之前先要知道它背后三套内核各自擅长什么。下面这张表是实际项目里最常见的判断依据内核常见协议HLS/MP4HTTPSRTSP包大小与集成成本IJKplayerHTTP/HTTPS/RTSP支持需要 openssl 支持支持较好SO 多体积大ExoPlayerHTTP/HTTPS/RTP支持默认支持从 2.14 开始提供基础 RTSP纯代码集成体量中等MediaPlayerHTTP/RTSP 系统实现部分配置麻烦设备差异大体积最小MediaPlayer胜在简单但 HTTPS 和 RTSP 的表现受系统厂商实现影响很大。同一段https://视频在 A 机型上能播在 B 机型上可能直接报Cant play this video。ExoPlayer的 HTTPS 和 ABR 策略都很完整但遇到 flv、特殊封装或低延迟 RTSP 直播时它的表现不一定比 IJKplayer 成熟。GSYVideoPlayer 的做法是不定死内核而是暴露GSYVideoType让你在运行期切换。注意不要只看基准性能。先列清楚要播的协议、封装格式和是否需要 HTTPS再决定默认内核。2.2 用 GSYVideoType 切换播放器如果要全局使用 IJKplayer在 Application 里设置一次即可public class App extends Application { Override public void onCreate() { super.onCreate(); GSYVideoType.setPlayerType(GSYVideoType.IJKPLAYER); } }GSYVideoType提供的取值有三种IJKPLAYER、EXOPLAYER、SYSTEMPLAYER其中SYSTEMPLAYER对应的就是 Android 系统的MediaPlayer。设置之后后续所有通过 GSYVideoPlayer 创建的播放器实例都会走同一个内核。如果只想切换当前实例可以在setUp之前对单个播放器设置GSYVideoType.setPlayerType(GSYVideoType.EXOPLAYER); player.setUp(https://example.com/media/movie.mp4, true, movie_key); player.startPlayLogic();这里的setPlayerType是全局静态方法切换后会影响之后新建的播放器所以要放在setUp之前。setUp的三个参数分别是视频地址、是否启用边播边缓存、缓存的唯一标识第三个参数最好不要用 URL因为 URL 里如果带签名参数缓存文件会一直重复生成用视频 ID 这类短字符串更可靠。2.3 HTTPS 支持与明文流量配置Android 9 开始默认禁止应用访问明文 HTTP 流量但 HTTPS 不在限制范围内。ExoPlayer 的 HTTPS 开箱即用IJKplayer 则需要底层 so 里编译进 openssl否则播放https://地址时通常只报failed to open或者直接无响应。作为工程兜底建议在res/xml/network_security_config.xml里把明文开关打开让 RTSP 的rtsp://源和测试阶段的 HTTP 源也能跑通network-security-config base-config cleartextTrafficPermittedtrue trust-anchors certificates srcsystem / /trust-anchors /base-config /network-security-config然后在AndroidManifest.xml的application节点里指向这个配置application android:networkSecurityConfigxml/network_security_config android:usesCleartextTraffictrue tools:targetApi28cleartextTrafficPermitted设为 true 后HTTP 和 RTSP 流量都会被放行但这等同于放弃了 Android 的明文流量保护。生产环境建议把base-config改成按域名收敛只允许特定直播域名走明文其余全走 HTTPS。2.4 布局与最小播放代码GSYVideoPlayer 的接入成本很低布局里直接放一个StandardGSYVideoPlayercom.shuyu.gsyvideoplayer.video.StandardGSYVideoPlayer android:idid/player android:layout_widthmatch_parent android:layout_heightwrap_content app:gsy_video_need_adjust_heighttrue /Activity 中调用StandardGSYVideoPlayer player findViewById(R.id.player); player.setUp(https://example.com/media/movie.mp4, true, movie_key); player.startPlayLogic();gsy_video_need_adjust_height用于让播放器根据视频宽高比自动调整高度避免黑边。startPlayLogic必须在视频布局已经 attach 到窗口后调用如果在onCreate里立刻调用可能因为宽高没有测量完导致画面比例异常。把setUp放在onCreate没问题但startPlayLogic建议放到onResume或者布局的回调里执行。3. 弹幕、外挂字幕、滤镜、水印、gif 截图与广告的实现路径3.1 弹幕层与播放时间的同步GSYVideoPlayer 本身不内置弹幕引擎但项目里最常用的组合是在播放器外层叠加一个DanmakuView。弹幕的难点不是显示而是和正在播放的画面同步。常见做法是在播放器onPrepared之后开一个定时任务把播放进度同步给弹幕 Viewplayer.setOnPreparedListener(v - { danmakuView.prepare(new DanmakuContext()); danmakuView.start(); }); handler.postDelayed(new Runnable() { Override public void run() { if (player.getCurrentPlayer() ! null player.getCurrentPlayer().isPlaying()) { long pos player.getCurrentPositionWhenPlaying(); danmakuView.seekTo(pos); } handler.postDelayed(this, 1000); } }, 1000);这段逻辑里seekTo(pos)的作用是让弹幕在拖动进度条后跳到对应时间点。但每秒都 seek 一次会有性能损耗项目里更稳妥的做法是只在onSeekComplete或拖动结束回调里 seek播放过程中只让DanmakuView自然滚动。3.2 外挂字幕加载与显示外挂字幕最通用的是 srt 格式解析后用自定义 View 画到画面上。可以把字幕抽成一个轻量类public class SubtitleView extends View { private String text; private final Paint paint new Paint(Paint.ANTI_ALIAS_FLAG); public SubtitleView(Context context) { super(context); paint.setTextSize(28f); paint.setColor(Color.WHITE); paint.setShadowLayer(4f, 0f, 2f, Color.BLACK); } public void setSubtitle(String text) { this.text text; invalidate(); } Override protected void onDraw(Canvas canvas) { if (text ! null !text.isEmpty()) { canvas.drawText(text, getWidth() / 2f - paint.measureText(text) / 2f, getHeight() * 0.85f, paint); } } }然后在播放进度回调里根据当前时间查找字幕条目。注意 srt 的时间格式是HH:mm:ss,SSS要先转成毫秒再比较long current player.getCurrentPositionWhenPlaying(); ListSubtitleItem items parseSrt(srtContent); for (SubtitleItem item : items) { if (current item.start current item.end) { subtitleView.setSubtitle(item.text); break; } }字母间距、换行、底部安全区都需要单独调尤其全屏时SubtitleView的父容器需要和播放器一起旋转否则字幕方向会错。3.3 滤镜与水印的叠加顺序GSYVideoPlayer 的渲染层基于GSYVideoGLView可以通过着色器做滤镜。在onInitSuccess或onPrepared阶段设置player.getGSYVideoManager().setLastRenderFirst(); player.setLeftTransform(new Matrix()); player.setNeedShowWifiTip(true);真正的滤镜在 GL 层做例如灰度滤镜的 Fragment Shaderprecision mediump float; uniform sampler2D sTexture; varying vec2 vTexCoord; void main() { vec4 tex texture2D(sTexture, vTexCoord); float gray dot(tex.rgb, vec3(0.299, 0.587, 0.114)); gl_FragColor vec4(vec3(gray), tex.a); }水印不用进 GL 管线。直接在StandardGSYVideoPlayer外层的FrameLayout里加一个ImageView放在播放器上方就能实现。水印要跟随视频旋转的话需要在onOrientationChanged里同步旋转水印 View否则视频旋转 90 度后水印还是正的观感会很奇怪。3.4 gif 截图与关键帧提取视频截图最可靠的 API 是系统的MediaMetadataRetriever它既支持 HTTPS 也支持本地文件。gif 截图的做法是先用它连续抽帧再交给AnimatedGifEncoder编码MediaMetadataRetriever mmr new MediaMetadataRetriever(); try { mmr.setDataSource(videoUrl, new HashMapString, String() {{ put(httpHeaders, Authorization: Bearer token); }}); Bitmap frame mmr.getFrameAtTime( 10_000_000L, MediaMetadataRetriever.OPTION_CLOSEST_SYNC ); // 将 frame 按时间间隔写入 AnimatedGifEncoder } finally { mmr.release(); }getFrameAtTime的第一个参数单位是微秒不是毫秒。要取第 10 秒的帧这里要传10_000_000L而不是10_000L。OPTION_CLOSEST_SYNC会找最近的关键帧速度最快但画面可能不是你想要的帧要精确到某一帧就用OPTION_CLOSEST代价是解码更慢。3.5 片头广告与中间广告的播放状态机广告插入本质上是一段“播放器状态切换”逻辑。用 GSYVideoPlayer 的setPlayTag标记当前播放对象是广告还是正片在setOnCompletionListener里做跳转player.setUp(adUrl, true, ad_key); player.setPlayTag(ad); player.setOnCompletionListener(mp - { if (ad.equals(player.getPlayTag())) { player.setUp(mainUrl, true, main_key); player.setPlayTag(main); player.startPlayLogic(); } }); player.startPlayLogic();中间广告类似但要注意正片进度。常见做法是广告播放前先把player.getCurrentPositionWhenPlaying()保存到变量广告播完后再setUp(mainUrl, ...)并用seekTo回到刚才的位置。片头广告还要注意不能触发自动全屏否则用户还没看到正片就进入了奇怪的全屏状态。4. 列表播放、旋转同步、边播边缓存与多路同时播放4.1 列表播放的回收与可见性RecyclerView 里的播放器不能每个 item 都持有完整播放器实例否则内存和解码器数量都会失控。常见做法是只保留一个正在播放的实例滑动停止后检查当前可见位置是否还是那个播放 item如果不是就暂停并释放资源recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrollStateChanged(NonNull RecyclerView rv, int newState) { if (newState RecyclerView.SCROLL_STATE_IDLE) { RecyclerView.LayoutManager lm rv.getLayoutManager(); if (lm instanceof LinearLayoutManager) { LinearLayoutManager llm (LinearLayoutManager) lm; int first llm.findFirstVisibleItemPosition(); int last llm.findLastVisibleItemPosition(); if (currentPlayPosition first || currentPlayPosition last) { GSYVideoManager.instance().releaseAllVideos(); currentPlayPosition -1; } } } } });GSYVideoManager 是 GSYVideoPlayer 用来管理全局播放器的入口。列表滚出屏幕后调用releaseAllVideos可以释放解码器、暂停网络拉流并恢复控制器状态避免“人走了声音还在”的问题。注意这里释放后对应 item 的onViewRecycled可能会重复触发释放最好加一个isReleased防重入标记。4.2 全屏动画与小窗口拖动GSYVideoPlayer 提供startFullscreen方法入参是宿主 Activity 和两个布尔开关player.startFullscreen(activity, true, true);第一个布尔参数控制是否隐藏 ActionBar第二个控制是否隐藏状态栏。全屏切换自带动画动画时长跟随系统缩放时长不需要自定义。但小窗口拖动需要自己做通常是在列表 item 里放一个 Float 型的小窗口容器监听onTouchEvent拖动整个容器位置float lastX 0; float lastY 0; windowRoot.setOnTouchListener((v, event) - { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: lastX event.getRawX(); lastY event.getRawY(); break; case MotionEvent.ACTION_MOVE: float dx event.getRawX() - lastX; float dy event.getRawY() - lastY; v.setTranslationX(v.getTranslationX() dx); v.setTranslationY(v.getTranslationY() dy); lastX event.getRawX(); lastY event.getRawY(); break; } return true; });小窗口拖动时要关闭父容器的点击事件传递否则拖动和点击全屏按钮会冲突。实际项目中我会只让小窗口的顶部区域响应拖动播放区域保留给点击事件这样体验更接近视频 App。4.3 重力旋转与手动旋转的同步视频自带 rotation 元数据时GSYVideoPlayer 会自动根据 rotation 做旋转。但手机重力感应引起的全屏切换和视频自身的 rotation 是两套逻辑要避免重复旋转。常见的处理是监听OrientationEventListener只在设备横屏角度变化时触发一次OrientationEventListener orientation new OrientationEventListener(this) { Override public void onOrientationChanged(int orientation) { if (orientation 0) return; if ((orientation 80 orientation 100) || (orientation 260 orientation 280)) { if (!player.isFullscreen()) { player.startFullscreen(activity, true, true); } } } }; orientation.enable();这里手动点击播放器上的全屏按钮时要同步禁用重力监听否则用户手动退出全屏后手机还是横着重力监听会立刻又拉回全屏形成“退不出去”的错觉。项目里常用的状态变量是isManualFullscreen手动进入全屏后isManualFullscreen true重力回调里先判断这个值手动退出全屏时再重置。4.4 边播边缓存与多路同时播放边播边缓存的开关就是setUp的第二个参数player.setUp(https://example.com/media/live.mp4, true, live_001); player.startPlayLogic();置为 true 后GSYVideoPlayer 会把网络流分块写入本地缓存文件缓存 key 使用第三个参数。要注意缓存目录文件数量过多后需要清理否则DiskLruCache的查找效率会下降。清理策略可以按“最近 7 天未使用即删除”来做。多个视频同时播放时GSYVideoManager 的单例设计会限制同时只有一个视频实例。在画中画或监控墙场景下直接创建多个StandardGSYVideoPlayer并各自setUp即可StandardGSYVideoPlayer playerA new StandardGSYVideoPlayer(context); StandardGSYVideoPlayer playerB new StandardGSYVideoPlayer(context); playerA.setUp(urlA, false, null); playerB.setUp(urlB, false, null); playerA.setVolume(1f, 1f); playerB.setVolume(0f, 0f); playerA.startPlayLogic(); playerB.startPlayLogic();多路播放时只有主画面保留声音其余播放器静音否则多个音频流同时输出会互相干扰。还要注意解码器数量限制部分低端设备只支持同时解码 2 到 4 路 H.264超过后会有Failed to instantiate decoder错误。5. RTSP 拉流、多分辨率切换与进度条预览5.1 RTSP 拉流协议与播放器参数RTSP 拉流在 rtsp 协议详解里最常见的问题是传输模式选 UDP 还是 TCP。UDP 延迟低但公网丢包后马赛克明显TCP 可靠但延迟会高一些。GSYVideoPlayer 使用 IJKplayer 内核时可以通过IjkMediaPlayer的原生参数控制if (player.getCurrentPlayer() instanceof IjkMediaPlayer) { IjkMediaPlayer ijk (IjkMediaPlayer) player.getCurrentPlayer(); ijk.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, rtsp_transport, tcp); ijk.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, max_delay, 200000); ijk.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, skip_loop_filter, 48); }rtsp_transport指定为 tcp 后视频数据走 TCP 连接不容易花屏。max_delay单位是微秒200000 表示 200 毫秒这是直播场景比较保守的缓冲值如果网络波动大可以调到 500000但延迟会明显增加。skip_loop_filter设置为 48 表示跳过所有环路滤波能降低解码压力代价是画质轻微下降。这些参数在 IJKplayer 6.1.1 里依然有效适合做低延迟直播调优。5.2 多分辨率切换清晰度切换的本质是播放地址变了。GSYVideoPlayer 里不需要销毁重建播放器直接再次调用setUp换地址public void switchResolution(String newUrl, String cacheKey) { long currentPosition player.getCurrentPositionWhenPlaying(); player.setUp(newUrl, true, cacheKey); player.seekTo(currentPosition); player.startPlayLogic(); }切换时保留currentPosition并 seek 回去用户的观看进度不会丢。但要注意多分辨率切换通常发生在视频加载前或用户主动选择时如果正片已经播了很久重新setUp会触发一次新的onPrepared所以要重新设置旋转矩阵和画面比例。GSYVideoPlayer 的GSYVideoType也提供屏幕比例常量使用方式效果GSYVideoType.SCREEN_TYPE_FULL全屏拉伸不留黑边GSYVideoType.SCREEN_TYPE_16_9强制 16:9裁剪超出部分GSYVideoType.SCREEN_TYPE_4_3强制 4:3GSYVideoType.SCREEN_TYPE_DEFAULT按视频原始比例自适应切到SCREEN_TYPE_FULL时画面会变形适合用户强制全屏时使用直播场景默认推荐SCREEN_TYPE_DEFAULT或SCREEN_TYPE_16_9避免主播画面被拉宽。5.3 进度条小窗口预览进度条预览通常用在点播场景用户拖动 SeekBar 时在进度条上方弹出一个缩略图。最直接的方式是配合MediaMetadataRetriever从本地缓存文件抽帧public void updatePreview(int positionMs) { MediaMetadataRetriever mmr new MediaMetadataRetriever(); try { mmr.setDataSource(cacheFilePath); Bitmap frame mmr.getFrameAtTime( positionMs * 1000L, MediaMetadataRetriever.OPTION_CLOSEST ); previewImage.setImageBitmap(frame); } catch (Exception e) { // 忽略抽帧失败不阻塞拖动 } finally { mmr.release(); } }实现拖动回调时要注意节流。不能onProgressChanged里每 10 毫秒抽一帧否则 MediaMetadataRetriever 会被频繁创建和释放导致卡顿。我一般只在ACTION_DOWN和ACTION_MOVE的最终位置触发预览并且在请求发出后用一个AtomicBoolean标记前一个抽帧任务未完成就不发下一个。对于远端视频更成熟的方案是服务端预生成缩略图列表客户端按时间点直接加载图片而不是实时解帧。6. 进阶技巧在 WSL 下编译带 Openssl 的 IJKplayer 6.1.16.1 为什么需要自己编译 IJKplayer预编译的 IJKplayer so 不一定包含 openssl也不一定包含完整 RTSP 解复用模块。要同时满足 HTTPS 和 RTSP 两个条件最可靠的办法是在本地自己编译一次。WSL 里编译 ijkplayer 已经有比较固定的流程只要把环境变量和 NDK 路径配好命令执行顺序不要乱一次编译通过的几率不低。6.2 准备 WSL 环境先安装基础工具链sudo apt update sudo apt install -y openjdk-8-jdk git make yasm然后导出环境变量export ANDROID_SDK_ROOT/mnt/d/android-sdk export ANDROID_NDK/mnt/d/android-ndk-r10e export PATH$PATH:$ANDROID_SDK_ROOT/platform-tools:$ANDROID_NDKANDROID_NDK必须指向 NDK r10e 或相近的历史版本新版本 NDK 移除了大量旧编译器工具链IJKplayer 的编译脚本无法直接兼容。如果系统里已经装了高版本 NDK不要把ANDROID_NDK指向它。6.3 编译命令顺序进入已经 clone 好的 ijkplayer 目录后按如下顺序执行./init-android.sh ./init-android-openssl.sh cd android/contrib ./compile-openssl.sh clean ./compile-openssl.sh all ./compile-ffmpeg.sh clean ./compile-ffmpeg.sh all cd .. ./compile-ijk.sh allinit-android.sh会拉取与 ijkplayer 匹配的 ffmpeg 源码分支init-android-openssl.sh负责引入 openssl 源码。接着的compile-openssl.sh必须先于compile-ffmpeg.sh执行因为 ffmpeg 在配置时会检测 openssl 是否存在检测不到就会自动关闭 HTTPS 协议支持。最后compile-ijk.sh all编译ijkplayer的 jni 层并生成各架构的 so。在 WSL 下执行compile-*.sh时如果遇到权限错误要先检查脚本是否有执行权限chmod x init-android.sh init-android-openssl.sh compile-*.sh如果编译中途因为内存不足退出在android/contrib/build.sh里把并发任务数从make -j4改成make -j2避免 WSL 默认分配的 CPU 核数过多导致 OOM。6.4 验证 HTTPS 与 RTSP 是否真的编译进包编译完成后android/ijkplayer/ijkplayer-arm64/src/main/jniLibs/arm64-v8a/下会生成一系列 so 文件。把它们集成进工程后做一次定向验证用 GSYVideoPlayer 播一段https://地址如果能进入onPrepared说明 openssl 生效再播一段rtsp://your-server/stream如果能正常出画面说明 RTSP 解复用模块可用。另一个值得验证的点是 rotation 元数据。用 ffmpeg 把一个普通视频的旋转信息改成 90 度再丢给 GSYVideoPlayer 播放ffmpeg -i input.mp4 -metadata:s:v rotate90 -codec copy output_rot90.mp4播放output_rot90.mp4确认画面能自动旋转而不是横躺在屏幕上。这个验证能同时把标题里提到的“视频自带 rotation 的旋转90,270之类”和“重力旋转与手动旋转的同步支持”一起测掉比自己猜测底层行为快得多。本文还有配套的精品资源点击获取