十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭Android视频播放器:ExoPlayer与分区存储避坑指南

从零搭Android视频播放器:ExoPlayer与分区存储避坑指南 简介这份Android视频播放器学习源码包围绕MediaPlayerSurfaceView、VideoView与Vitamio三种典型实现方案展开适合初入Android多媒体开发的读者对照学习。资源包共124个文件其中36个java文件对应三种播放器的核心逻辑与界面控制33个so文件为Vitamio库的底层解码支撑另有16个xml布局与配置文件、18个png界面切图整体仅8.56MB便于下载与工程导入。注意使用时应同时导入源码module与Vitamio的library模块并可在清单文件中切换启动页面快速查看不同方案的运行效果。已有665人学习下载对希望理解Android视频播放器从系统API到第三方库集成差异的开发者来说是一份紧凑且可运行的参考实现。 从零搭一个能用的 Android 视频播放器真不是把 VideoView 拖进布局里就完事的事。尤其是现在 Android 11 往后分区存储强制生效、各家厂商解码器又各玩各的光是“让用户选一个视频并顺利播出来”这一关就能卡掉不少人。这几天我在整理一个播放器项目把踩过的坑和最终落地的方案一次性说清楚。这套东西适合谁看打算自己上手做播放器应用、或者需要在现有 App 里嵌入视频模块的开发者。看完你至少能搞清楚三件事播放内核怎么选、文件/权限链路怎么走、常见问题怎么查。1. 项目整体思路与播放器选型1.1 一开始为什么没直接用 VideoView很多新手第一次做播放器顺手就拖一个 VideoView 进来设置好路径调用 start()确实能跑。但跑通和能交付是两回事。VideoView 底子是 MediaPlayer SurfaceView 的封装它把控制逻辑、渲染逻辑和生命周期都焊死在一起你没法单独替换解码器也没法精细控制缓冲策略。一旦遇到非标准码率的视频、网络流抖动、需要倍速播放这类需求改起来会非常痛苦。我自己在这个项目里直接选了Media3 的 ExoPlayer。它是当前 Android 官方主推的播放内核底层在 1.x 版本开始整合进了 androidx 体系后续升级维护都有保障。相比 VideoViewExoPlayer 把媒体源、渲染器、跟踪选择器这些模块拆开了你可以按需装配比如只保留 H.264 软解、加自定义的缓存策略、接入 DRM 等自由度完全不一样。1.2 要不要用第三方播放器 SDK做选型的时候很多人会纠结直接用开源播放器壳比如 IJKPlayer、vkplayer或者接商业 SDK。我的建议是分场景。如果你的需求只是“能播主流格式、UI 好看”那么成熟的开源播放器壳确实省事。但一旦涉及深度定制比如需要自定义解码器调度、做低延迟直播链路、对接自己的埋点体系第三方壳反而会成为约束。这次项目里我选择 ExoPlayer 而不是 IJKPlayer还有一个现实原因ExoPlayer 对 Android 新版本的适配跟得更快。项目要适配 Android 13、14 的设备ExoPlayer 在权限变更、音频焦点、生命周期这些方面都有官方组件支持省去很多自己兼容的功夫。2. 文件访问与 Android 11 权限适配2.1 分区存储真正改变了什么如果你的 targetSdk 还在 29 以下那直接传一个file:///storage/emulated/0/...路径给播放器可能一切正常。但从 Android 11 开始分区存储强制开启App 不能直接访问公共目录下其他应用创建的文件更不能靠拼接绝对路径去读视频文件。这时候很多人会遇到一个经典错误播放器拿到一个content://开头的 URI结果设置完数据源直接报无法播放。这个问题的本质是content URI 不是持久化的文件路径而是一次性的访问凭证。你在文件选择器里拿到的 content URI可能只对当前进程、当前授权会话有效。如果应用被杀掉重启或者你把 URI 存进数据库下次再读对应的读权限其实已经失效了。热搜词里那一长串content://com.xxx.fileprovider/baiddpath/...的路径大家应该不陌生这都是系统文件选择器回传的 URI 格式并不是真实的文件位置。2.2 正确读取视频文件的两种姿势第一种推荐方案用系统文件选择器ACTION_OPEN_DOCUMENT选取视频获取 content URI 后在应用内使用ContentResolver.openFileDescriptor()获取文件描述符转交给播放器。这样做的好处是通过 SAF 框架拿到的权限是长期授权应用重启后依然有效。前提是你在读取时用takePersistableUriPermission()申请持久化权限。第二种方案如果只是读取自己应用录制的视频直接放到应用专属目录/Android/data/包名/files/下用getExternalFilesDir()获取路径这种位置完全不需要额外权限。很多应用把“下载到本地再播放”的逻辑做错了就是因为在公共目录读文件被分区存储挡了。我在项目里实际的代码是这样处理的// 通过系统文件选择器选视频 val intent Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type video/* } startActivityForResult(intent, REQUEST_VIDEO_PICK) // 在 onActivityResult 中持久化权限并交给播放器 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode REQUEST_VIDEO_PICK resultCode Activity.RESULT_OK) { val uri data?.data ?: return contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) player.setMediaItem(MediaItem.fromUri(uri)) player.prepare() player.play() } }补充一句Android 13 又把媒体权限细化成了READ_MEDIA_VIDEO如果你的应用需要扫描公共目录里的视频文件别忘了在清单里声明新权限并在运行时请求。只声明旧的READ_EXTERNAL_STORAGE在 13 以上是不生效的。3. 核心播放能力搭建3.1 依赖引入与最小播放链路用 Media3 的优势之一就是依赖清晰。我当前项目用的版本是1.3.x在build.gradle里加这几行implementation(androidx.media3:media3-exoplayer:1.3.1) implementation(androidx.media3:media3-ui:1.3.1)如果不需要自带 UI可以不引入media3-ui自己用 TextureView 渲染。但调试阶段建议还是带上它自带的PlayerView能快速验证播放链路通不通后续再换成自定义 UI。最小播放链路是这样// 创建播放器实例 val player ExoPlayer.Builder(context) .setAudioAttributes( AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build(), true // handleAudioFocus true ) .build() // 绑定渲染视图 binding.playerView.player player // 设置数据源并播放 val mediaItem MediaItem.fromUri(videoUri) player.setMediaItem(mediaItem) player.prepare() player.play()有一个特别容易被忽略的细节handleAudioFocus参数设为 true 之后播放器会自动处理音频焦点。比如你在播放视频时突然来了微信语音系统会短暂降低音量或者暂停播放等语音结束再自动恢复。如果你把这个参数漏了会出现播放画面正常但声音突然“消失”的情况排查起来非常绕。3.2 生命周期管理与释放策略播放器这玩意儿最忌讳的是只 create 不 release。试想一下你从一个页面跳到另一个页面旧页面的播放器还在后台解码不仅白烧 CPU还会导致声音继续外放。Android 设备上最常见的“返回了还在响”的问题大概率就是生命周期没有处理好。我在项目里直接用 ViewModel 来持有播放器实例让播放器的生命周期跟着 ViewModel 走class PlaybackViewModel : ViewModel() { val player: ExoPlayer by lazy { ExoPlayer.Builder(getApplicationApplication()).build() } override fun onCleared() { player.release() super.onCleared() } }页面在onPause()时调用player.pause()在onResume()时不要盲目 play而是根据用户操作状态决定。比如用户是主动暂停的那么回到页面就不该自动播放否则体验会很恼人。这个逻辑我用一个isPlayWhenReady的标记来管理每次切换页面时同步状态。4. 细节打磨手势、列表播放与网络视频4.1 手势控制亮度和音量视频播放器做到能播只是及格手势交互才是提升体验的关键。常见的需求是左侧上下滑动调亮度右侧上下滑动调音量底部左右滑动调进度。这部分我直接复用了网上很多成熟方案核心思路是把触摸事件定位到屏幕左右两侧区域分别处理。这里有一个容易踩的坑屏幕亮度调节需要先检查Settings.System.canWrite()权限如果没有授权直接修改 Window 的亮度属性是不生效的。我在项目里优先尝试设置WindowManager.LayoutParams.screenBrightness它只对当前窗口生效不依赖系统写权限相对安全。音量调节则简单一些用AudioManager.setStreamVolume()就行注意要和播放器实例设置的 AudioAttributes 里的 stream 类型一致不然会出现“调音量没反应”的错觉。4.2 列表播放与缓存优化如果播放器是用在信息流场景每条视频自动播放那种那一定要处理“预加载”和“缓存复用”。ExoPlayer 提供了DefaultMediaSourceFactory可以配合CacheDataSource做本地缓存。我第一次接缓存的时候犯了个错误缓存目录直接放在了应用私有目录下结果视频一多缓存文件把磁盘撑满了。后来给缓存设置了最大大小和 LRU 清理策略。val cache SimpleCache( cacheDir, LeastRecentlyUsedCacheEvictor(200 * 1024 * 1024) // 上限 200MB ) val cacheDataSourceFactory CacheDataSource.Factory() .setCache(cache) .setUpstreamDataSourceFactory(DefaultHttpDataSource.Factory()) val player ExoPlayer.Builder(context) .setMediaSourceFactory(DefaultMediaSourceFactory(context).setDataSourceFactory(cacheDataSourceFactory)) .build()列表滑动时的另一个细节是不要在 onBindViewHolder 里直接调用 player.play()。滑到一条视频就开始播会造成假死和卡顿。正确做法是监听 RecyclerView 的滑动停止事件找到当前可见并且覆盖率最大的那一条再切换播放。实战下来效果比每一条都抢着播放要顺滑得多。4.3 网络视频加载失败的兜底现在视频播放器基本都要支持远程 URL。但网络环境是不可控的我在项目里遇到最多的问题是视频加载超时、返回 403、HLS 分片拉取失败。ExoPlayer 提供了LoadErrorHandlingPolicy可以自定义重试次数和退避策略。实际项目中我把超时时间从默认的 8 秒调到了 15 秒重试策略改为指数退避明显改善了弱网下的成功率。另外如果你的视频 URL 经过 CDN 签名有效时间较短播放器缓存了旧链接会导致播放失败。这时候要在拼接 MediaItem 的时候用最新 URL而不是从数据库里读一个陈旧的字符串。5. 常见问题排查实录5.1 视频“只能听到声音、画面黑屏”这类问题十有八九是渲染器没选对。ExoPlayer 默认的视频解码优先走硬件但部分设备上硬解码对某些 H.264 编码的视频兼容性极差会出现黑屏但有声音。排查方法是先强制切软解测试val trackSelector DefaultTrackSelector(context).apply { setParameters(buildUponParameters().setForceLowestBitrate(false)) }更直接的方式是在构建播放器时增加RenderersFactory把MediaCodecVideoRenderer的初始化解码器优先级调低或者干脆禁用MediaCodecSelector里的硬解项。不过要注意强制软解非常耗电不能作为长期方案只能用来定位问题。定位到是硬解兼容性后可以对具体机型做配置下发。5.2 部分视频播放失败、提示格式不支持先确认视频封装格式和编码格式ExoPlayer 原生支持的格式其实比很多播放器都多但不代表所有封装都包含默认 Extractor。如果遇到.wmv、.rmvb这类老旧格式默认配置下基本播不了。解决方案有两个一是服务端转码成 H.264 AAC 的 MP4二是在端侧增加附加解码器。这里分享一个经验不要为了支持小众格式把播放器搞得太重。我在项目早期接入了一大堆扩展库包体积直线上升Crash 率不降反增。后来统计发现这些格式的使用率不足 0.1%果断砍掉只保留主流的 MP4、HLS、DASH 支持。常见问题我整理了一张表方便排查现象可能原因排查方法黑屏有声音硬件解码兼容性差切换软解或换测试机无法播放 content URI缺少 URI 持久化授权检查 takePersistableUriPermission远程视频一直加载失败网络超时/签名过期调大超时时间检查 URL 有效性列表滑动卡顿在 bind 里直接创建播放器改为复用单例播放器返回页面后声音仍在播生命周期未释放使用 ViewModel 管理播放器5.3 崩溃与内存溢出视频播放器的内存泄漏高发区是Context引用。播放器实例如果持有 Activity 的 Context在页面销毁后没有及时释放就会出现泄漏。构建播放器时尽量传入applicationContext。另外PlayerView如果和 Activity 绑定记得在onDestroy中置空引用。图片缩略图生成也容易 OOM。获取视频缩略图一般用MediaMetadataRetriever.getFrameAtTime()这个 API 返回的 Bitmap 分辨率可能非常高如果你直接把它塞进 ImageView低端机很容易崩。我习惯把取到的帧统一缩放到目标宽度再显示比如列表封面统一 480px 宽度能省一大块内存。6. 构建与上线需注意的事6.1 AGP 版本与构建配置这个项目我用的开发环境是 Android StudioAGP 版本升级到 8.x 之后有个明显变化是 Gradle 配置要求更严格了比如namespace必须显式声明compileSdk建议更新到当前稳定版本。如果你还在用旧版本 AGP 跑新项目可能会遇到奇怪的构建失败优先检查 AGP 和 Gradle 版本是否匹配。附一个我目前能稳定编译的版本组合供参考组件版本Android StudioHedgehog 2023.1.1 Patch 2AGP8.2.xGradle8.2 以上Kotlin1.9.xMedia31.3.x如果你在升级过程中遇到“Could not load compiled classes for settings file”这类问题基本都是 Gradle 缓存不对清掉.gradle目录后重新 Sync 就能解决。6.2 混淆配置与包体积控制发布版开了 R8 之后Media3 的混淆规则一般会自动带过去但有些自研的播放器回调接口如果被反射调用需要在proguard-rules.pro里手动 keep。我的习惯是把播放器相关的类统一放到一个包下然后集中 keep-keep class com.example.player.** { *; } -keep class androidx.media3.** { *; }另一个能明显降低崩溃率的是多机型灰度测试。视频解码这东西线上用户设备五花八门高通、联发科、麒麟、入门机、折叠屏解码行为差异大得离谱。我在项目的测试阶段专门找了一批低端机做专项回归修复了不少在高端机上根本不会出现的兼容性问题。现在流行用性能分析工具抓播放器功耗和卡顿比如 Android Studio 自带的火焰图CPU Profiler 的火焰图视图能直观看到播放过程中哪些线程占用了过高 CPU排查定位解码线程疯跑的问题效率非常高。最后聊点实际的做完这轮播放器项目我最大的感受是播放器坑多但大多数坑都有固定的排查路径。遇到问题先别慌着换库先确认是权限问题、解码问题还是生命周期问题。只要链路清晰对症下药大部分问题都能在半小时内定位。最后再分享一个小技巧如果你开发阶段经常要验证不同格式的视频可以先用系统自带的播放器测试同一个视频是否正常。如果系统播放器也播不了那基本能断定是视频文件本身的问题而不是你的播放器写错了。这个排除法看起来笨但真的能省不少排查时间。后续如果要做可以考虑在现有播放器上加入画中画模式、倍速记忆、字幕切换这些功能。核心播放链路稳定之后这些扩展都是顺水推舟的事。本文还有配套的精品资源点击获取
返回列表