Android视频播放器实战:Media3替代MediaPlayer的完整实践
2026/9/3 18:18:07 网站建设 项目流程

简介:面向Android开发者的视频播放器实现源码,集中展示MediaPlayer+SurfaceView、VideoView与Vitamio三种主流方案的完整代码,适合从入门到进阶的开发者对照学习,也可作为项目集成与二次开发的参考模板。压缩包共124个文件,整体8.56MB,其中包含36个Java源文件、33个Vitamio库所需的so动态库、18个PNG图片资源与16个XML布局/配置,另有Gradle、properties等工程配置,两个module分别对应演示源码与Vitamio官方library,导入后可通过清单文件切换启动页面,快速查看不同实现效果。目前已有665人学习下载。读者既能对比三种播放方式的实现差异,也能掌握SurfaceView画面渲染、MediaPlayer状态控制、VideoView封装逻辑以及Vitamio扩展能力,按目录结构阅读可高效吸收关键知识点,对实际项目选型和技术积累均有帮助。 做Android开发这些年,我接过不少播放器相关的需求,身边的同事也经常问:想做一个带进度条、倍速、列表播放的“正经”视频播放器,到底从哪儿下手。之前网上资料普遍停留在用MediaPlayer包一层 SurfaceView 的阶段,写着写着就发现控制逻辑、缓冲状态、生命周期切换全都得自己啃,代码越写越厚,播放效果反而一般。后来我全面切到 Media3 这套方案,才算真正把“播放器”做成了能稳定上线的东西。这篇内容主要面向想从零搭一个 Android 视频播放器的开发同学,也适合那些已经写了段时间页面、但一直没弄明白播放内核、文件权限、编译环境这些关联问题的人。我不打算给你贴官方文档,而是把我实际搭建和调试的过程、踩过的坑、最后的取舍都摊开讲清楚,你能直接照着复现。

1. 播放器内核选型:为什么我最终用 Media3 而不是 MediaPlayer

1.1 系统自带 MediaPlayer 的边界在哪里

Android 系统自带的MediaPlayer不是不能用,它适合那种“播放单个 MP4、界面不需要太多定制”的场景。我刚入门那会儿也拿它写过 Demo,一个 URL 丢进去,setDisplay拿到 Surface,然后 start,看起来挺简单。

但一旦需求稍微复杂点,问题就全冒出来了。

  • 缓冲进度、加载状态这些细节,MediaPlayer 给的回调很粗,你很难拿到精细的网络带宽和缓冲百分比。
  • 列表连续播放要自己监听OnCompletionListener,还得自己管理下一个视频的预加载,稍微一卡顿就白屏。
  • 倍速播放效果在部分机型上声音会变调,setPlaybackParams的支持程度在不同硬件上差异很大。
  • HLS、DASH 这类流媒体协议,MediaPlayer 支持得很勉强,自适应码率切换更是基本指望不上。
  • 更别说自定义 UI、字幕、音频轨道切换,全得自己从底层往上补。

所以如果你要做一个功能稍微完整点的播放器,MediaPlayer 只能当学习材料,不适合当生产工具。

1.2 Media3 解决了什么问题

Google 后来把 ExoPlayer 整合进了 Media3 这个大项目里,现在官方建议就是用 Media3 作为统一媒体库。它的核心思路是把播放器拆成多个可替换的模块:资源加载、音视频解码、渲染输出、扩展控制全部解耦。你想要什么能力,就往里面挂对应模块,不需要的就不要引入,包的体积也能控制住。

我用的版本是androidx.media3:media3-exoplayermedia3-ui,先把最基础的依赖放上来:

dependencies { implementation "androidx.media3:media3-exoplayer:1.4.1" implementation "androidx.media3:media3-ui:1.4.1" }

从实际开发体验来说,Media3 解决了我几个长期痛点:

  • ExoPlayer内部自己管理了缓冲策略,列表播放时支持预加载下一个媒体项,切视频非常顺滑。
  • TrackSelector可以直接选择清晰度、音轨、字幕轨,不用自己解析底层格式。
  • 支持PlaybackParameters调倍速,且声音质量稳定得多。
  • UI 层提供了一个现成的PlayerView,进度条、播放/暂停、缓冲动画、横竖屏切换按钮都有,你不用从零画一个播放器界面。

有的同学会问,那第三方的ijkplayerVLC之类的播放器不是也很好吗?确实,在特定场景下它们有优势,比如 IJK 对 RTSP 的支持比较好,VLC 的软解能力出色。但从谷歌趋势和社区维护状态看,Media3 已经是官道长线维护的项目,API 设计也更贴近 Android 本身的生命周期和 Compose 生态。除非你有非常特殊的流协议需求,否则在普通 App 里做内嵌视频播放,Media3 是最稳的起点。

2. 搭建项目时最容易翻车的编译环境

2.1 Android Studio 版本与 AGP 版本怎么匹配

很多同学新项目一创建就报各种编译错误,头一天就卡在环境上。最典型的坑就是 Android Studio 版本和 Android Gradle Plugin(AGP)版本对不上。

我见过网上问“Android Studio Hedgehog 2023.1.1 Patch 2 支持 AGP 8 吗”这种问题。答案是支持,但要注意的是,Hedgehog 这个版本自己带了 AGP 的上限。一般情况下,Hedgehog 内置的 Gradle 能支持到 AGP 8.2 左右,compileSdk可以跑到 34。

我用的是 Hedgehog 加 AGP 8.2.2 的组合,线上跑了大半年没毛病。这里放一个我验证过的版本组合作为参考:

项目版本
Android StudioHedgehog 2023.1.1 Patch 2
Gradle8.2
AGP8.2.2
Kotlin1.9.22
compileSdk34
targetSdk34

要判断自己本地的 AS 到底支持哪个 AGP 版本,最直接的办法是在Gradle Scripts -> gradle-wrapper.properties里看 Gradle 版本,然后在项目的build.gradle里把 AGP 版本设置在对应的兼容区间内,不要随手拉一个最新的 AGP 就往上怼。

2.2 常见编译错误:setting 文件加载不了 Kotlin 类

我搭播放器项目的时候踩过一个挺有代表性的编译错误:

Could not load compiled classes for settings file '...\settings.gradle.kts'.

这个问题出现的原因不是你的代码写错,而是 Gradle 在初始化阶段执行 setting 脚本时,Kotlin DSL 编译缓存失效。常见诱因包括:更换了 JDK 版本、Android Studio 升级后 Gradle 版本变化、gradle-wrapper.properties里 distributionUrl 被改过,但 Gradle daemon 还保留着老的缓存。

解决手段其实很粗暴:

  1. 关掉 Android Studio。
  2. 手动删除项目根目录下的.gradle文件夹以及build文件夹。
  3. 打开settings.gradle.kts和根目录build.gradle.kts,检查插件版本和仓库地址。
  4. 重新用“Sync Project with Gradle Files”执行同步。

如果还不行,就直接在命令行执行一次gradle clean,或者把~/.gradle/caches里对应项目的编译缓存删掉。不要怕删缓存,Gradle 会自动重建,只是第一次会慢点。

我个人的习惯是:遇到这种问题先不急着怀疑代码,肯定是环境同步出了问题,排查优先级就是 JDK 版本、Gradle 版本、AGP 版本、缓存四个方向,按这个顺序捋一遍基本能解决。

3. 核心实现:写出一个能直接上线的播放器页面

3.1 布局设计与控制器绑定

Media3 的 UI 已经帮我们封装了很多东西,PlayerView是一个可以直接放进布局文件的组合控件。我一般这样写布局:

<androidx.media3.ui.PlayerView android:id="@+id/player_view" android:layout_width="match_parent" android:layout_height="wrap_content" app:use_controller="true" app:controller_layout_id="@layout/custom_player_controller" />

use_controller设为 true 时,控件自带一个默认控制条,点击屏幕就会浮现。默认控制条包含播放/暂停、进度条、时间文字,基本够用。

如果你想要倍速按钮、全屏按钮、清晰度切换入口,就需要做自定义控制器布局。我通常的做法是:拷贝一份 Media3 源码里的default_controller_layout.xml,然后添加自己的按钮,一个典型的自定义控制器会有这几部分:

  • 播放/暂停按钮
  • 进度条和时间显示
  • 倍速按钮
  • 全屏切换按钮
  • 音轨或字幕切换入口

自定义后,需要在PlayerView上进行绑定:

playerView.setControllerLayoutId(R.layout.custom_player_controller)

这里有个小经验:默认控制器在屏幕底部的布局,首次进入时建议设置playerView.controllerAutoShow = true,让用户点开机后控制条立刻出现,交互反馈会更直接。

3.2 Player 的初始化、释放与生命周期管理

播放器对象不是随用随丢的,一个稳定的播放器必须在 Activity 或 Fragment 的生命周期内做到“精确创建”和“精确释放”。我通常使用的模式是:

private var player: ExoPlayer? = null private fun initializePlayer() { player = ExoPlayer.Builder(context) .setSeekBackIncrementMs(10000) .setSeekForwardIncrementMs(10000) .build() .also { exoPlayer -> playerView.player = exoPlayer exoPlayer.setMediaItem(mediaItem) exoPlayer.prepare() exoPlayer.playWhenReady = true } } private fun releasePlayer() { playerView.player = null player?.release() player = null }

然后在 Activity 中把生命周期绑定好:

  • onStart里如果当前没有播放器就initializePlayer()
  • onStop里暂停播放,视需求决定是否释放;后台播放场景需要特殊处理。
  • onDestroy里必须releasePlayer(),否则会内存泄漏。
  • onSaveInstanceState里把当前播放位置保存下来,转屏后恢复。

还有一点容易忽略,就是音频焦点。视频 App 一般不需要后台传音,但你播放过程中来了电话或者对方 App 抢了音频焦点,播放器要能自觉暂停。网上很多播放器没有处理 AudioFocus,结果用户看视频时来了通知,声音直接变成“双声道打架”。

3.3 横竖屏切换与手势快进快退

现在的播放器,横竖屏切换基本是标配。Android 12 之前常规做法是在配置变更里手动切换布局,Android 12 开始官方提供了setRotation的过渡动画,但实际开发中我还是习惯手动处理,因为播放器的控制栏在全屏和窗口模式下通常有不同布局。

我这里给一个在onConfigurationChanged里切换全屏状态的参考:

override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { // 隐藏状态栏,全屏显示 PlayerView window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN params = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) } else { // 恢复竖屏比例,比如 16:9 或 4:3 window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE } playerView.layoutParams = params }

如果你愿意花点时间,我建议做一个相对平滑的动画过渡,而不是瞬间跳变。

手势方面,Media3 的 PlayerView 没有内置双击快进和滑动调节进度,需要自己实现GestureDetector。双击右侧屏幕快进 10 秒、左侧回退 10 秒,这个交互非常符合用户习惯。我在项目里会在PlayerView上挂一个SimpleOnGestureListener

gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onDoubleTap(e: MotionEvent): Boolean { val width = playerView.width if (e.x > width / 2) { player?.seekForward() } else { player?.seekBack() } return true } })

注意,seekForward()seekBack()是需要自定义配置增量的,我在初始化时设置了setSeekForwardIncrementMs(10000),正好配合双击手势。用户实际体验下来反馈很正向,因为手指不用再精准拖拽进度条了。

3.4 播放列表、倍速与字幕的细节处理

列表播放是视频类 App 最常用的需求之一。Media3 对播放列表的支持非常成熟,你只需要把多个 MediaItem 一次性传给播放器:

val firstItem = MediaItem.fromUri("https://example.com/video1.mp4") val secondItem = MediaItem.fromUri("https://example.com/video2.mp4") player?.setMediaItems(listOf(firstItem, secondItem), startIndex = 0, startPositionMs = 0L) player?.prepare()

播放完第一个会自动切到第二个,如果有片头片尾、选集这种连续看剧的场景,列表模式几乎是必须用上的。而且它内部的预加载机制会提前把下一个视频的缓冲准备好,切集时肉眼几乎没有等待。

倍速播放我建议用PlaybackParameters

player?.playbackParameters = PlaybackParameters(1.5f, 1f)

第二个参数是 pitch,保持 1.0 才不会让声音变尖。Media3 对倍速的音频处理做得比较均衡,没有以前 MediaPlayer 那种明显的“电子音”问题。字幕方面,如果视频自带字幕轨,用TrackSelector就可以在运行时切换;如果是独立的.srt文件,通常需要先解析后通过SidecarConfiguration绑定到 MediaItem 上,这部分对于视频 App 不是高优需求,我一般是二期再上,避免一开始功能面铺太开。

4. 播放本地视频绕不开的内容 URI 与文件访问权限

4.1 content://、file://、SAF 到底怎么选

很多人在播放器里加载本地视频时都会遇到一个问题:拿到了一个路径,但播不出来。这不是播放器的问题,而是你根本不应该直接拿文件路径去播放。

Android 从 7.0 开始就引入 FileUriExposedException,限制了file://Scheme 在跨应用间传递;到 Android 11 以后,Scoped Storage 进一步限制了对公共目录的随机访问。我实测下来,正确方式只有一条:通过content://形式的 URI 访问。

如果你用系统文件选择器去选视频,拿到的回调是一个content://Uri:

ActivityResultContracts.OpenDocument()

选择文件后,你可以直接把这个 Uri 传给播放器:

val mediaItem = MediaItem.fromUri(uri) player?.setMediaItem(mediaItem)

不需要申请任何存储权限,这也是我最推荐的方式。因为 Media3 底层用的是ContentResolver来打开文件,对于content://Uri 天然支持。

如果你是读取自己应用私有目录或应用专属目录里的视频,直接用绝对路径也合法。但凡涉及读取公共存储图片或视频,不要碰Environment.getExternalStoragePublicDirectory,在 Android 11+ 上这条路基本走不通。

4.2 使用 Storage Access Framework 打开本地视频

完整流程是这样:

  1. 在页面里注册一个ActivityResultLauncher
private val openVideoLauncher = registerForActivityResult( ActivityResultContracts.OpenDocument() ) { uri: Uri? -> uri ?: return@registerForActivityResult contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION ) playVideo(uri) }
  1. 通过按钮触发系统文件选择器:
openVideoLauncher.launch(arrayOf("video/*", "application/octet-stream"))
  1. 拿到 uri 后,直接绑定到播放器。

takePersistableUriPermission这一步非常关键。如果你只是临时播放,可以不调用它;但如果你需要把这个视频加进“最近播放”列表,下次 App 启动还要继续播放,就必须申请持久化 URI 授权。否则 App 重启后,那个 content:// Uri 就会失效。

实际测试下来,系统文件选择器对 MP4、MKV、WebM 等常见格式识别都很顺畅。某些小众格式或者无扩展名的视频文件,媒体类型会返回application/octet-stream,所以上面我特意在 launch 的类型数组里加了这个,避免选完文件后出现“无法打开”的情况。

5. 实操中踩过的坑与排查方法

5.1 虚拟设备无效:AVD 启动就黑屏或直接报错

开发阶段我经常遇到模拟器启动不了的问题,很多同学会以为是代码问题,其实大部分是 AVD 配置和系统的虚拟化支持之间没对齐。

常见的现象是:模拟器能启动,但界面一直停在开机动画,普通 Android 版本还能等,新版本系统基本会卡到死机。排查顺序如下:

  1. 确认 BIOS 里开启了硬件虚拟化,Windows 端可用任务管理器查看“虚拟化”是否启用。
  2. Android Studio 里 AVD Manager 的 Device 设置里,Graphics 切换为 “Software — GLES 2.0”,能解决不少渲染启动崩溃。
  3. 检查 Android SDK Platform-Tools 版本,模拟器内核和 adb 版本需要匹配。
  4. 如果项目依赖的 SDK 版本高于模拟器的系统镜像版本,应用可能直接安装失败。

如果你用的电脑配置一般,我建议直接拿真机调试视频播放,因为编解码器在模拟器上表现和真机差异很大,很多 H.265 视频在模拟器上会因为缺少硬件解码器而黑屏,但在真机上一切正常。“虚拟设备无效”不一定是设备坏,更多时候是环境没对齐。

5.2 播放器崩溃:解码失败、缓冲黑屏的处理

播放视频最让人头疼的往往是“黑屏但声音正常”或者“播放进度条在走但画面卡住”。这种问题八成跟视频编码格式和硬件解码能力有关。

Media3 默认会优先走硬件解码器,如果当前设备硬件不支持该编码格式,就会闪退或黑屏。一个比较稳的处理是启用降级逻辑,在构建播放器时设置启用软件解码器作为兜底:

val videoRendererFactory = DefaultRenderersFactory(context) .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)

但这只能解决部分问题,真正稳妥的做法是在onPlayerError里拦截错误信息,针对PLAYER_ERROR_VIDEO_DECODE_FAILED这类错误,尝试切换到软解模式重建播放器。说到底,视频 App 一定要有“错误提示 + 重新尝试”的交互,不然用户只会看着一个黑屏一脸问号。

另一个常见问题是弱网环境下缓冲一直没有结束,经验值是视频加载 5 秒内没出画面,就要主动提示“网络不佳”并给重试按钮。Media3 里可以通过监听Player.Listener里的状态变化来判断:

  • STATE_BUFFERING进入缓冲
  • 超过阈值后调用player.setForegroundMode(false)之类操作

不要赌用户有耐心等,视频播放器的体验就是快、准、稳。

5.3 性能分析:火焰图不是玄学,但要注意姿势

热门词里出现了“android studio 火焰图 指南”,这个东西在排查播放器卡顿、掉帧的时候确实有用,但我要说清楚,火焰图是性能分析工具的一种可视化方式,重点不是看图的形状,而是找到哪一层函数占用了大量 CPU。

Android Studio 自带的 CPU Profiler 可以录制 Java 层方法的调用堆栈。当视频播放时卡顿,我会先录制一段从播放到卡顿出现的过程,然后看火焰图里哪个函数在卡顿期间最宽。通常问题出在以下几个方面:

  • 解码后转 Bitmap 时频繁内存分配,导致 GC 压力大。
  • UI 线程做了网络请求或文件读取。
  • 自定义控制器布局里写了过度复杂的绘制逻辑。

如果火焰图里显示主线程长时间占用,那代码问题没跑;如果显示解码线程全满,那大概率是当前机器软解顶不住高分辨率视频。此时就该考虑降低清晰度或切换解码方案,而不是继续堆代码。

5.4 权限相关记录:为什么申请了权限还是读不到视频

常见问题里有一个非常典型:明明在 AndroidManifest 里写了READ_EXTERNAL_STORAGE,代码运行时也授予了权限,但用MediaStore查询不到任何视频。

这多半是因为 targetSdk 和宿主机版本不一致。Android 13 开始,读取视频文件的权限从READ_EXTERNAL_STORAGE细分成了READ_MEDIA_VIDEO。如果你的 targetSdk 已经到 33 或以上,就不要再依赖旧的宽泛权限了,需要单独申请:

<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

如果是比较老的设备(Android 12 以下)再用READ_EXTERNAL_STORAGE,反正权限逻辑要按系统版本做分支。这里最容易踩的坑就是 Unity 调试或者旧项目升级后忘了动态判断权限,结果高版本设备上一直返回空列表。

我的建议是:播放器应用最好设置 targetSdk 为 34,并在AndroidManifest.xml里同时声明两个权限(旧版和新版),然后在运行时按Build.VERSION.SDK_INT分别申请。这样兼容性最优,也不影响正常使用。


最后再分享一个我在实际项目里沉淀下来的小习惯:播放器页面一定要在 Debug 模式下加上“当前解析信息”的调试浮层,比如视频分辨率、编码格式、缓冲状态、丢弃帧率、当前渲染器类型。平时不显示,打开开关后立刻能看到播放器内部到底在干什么。很多时候你觉得“莫名其妙卡了”“画质变差了”,浮层一开,原因一眼就明朗——要么是切到软解了,要么是缓冲策略在反复横跳。做成功能之前,先让自己能一眼看穿播放器的五脏六腑,这比任何花哨的优化都管用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询