interface PlayerPreProcessor { val name: String / Processor id,业务自定义的 id 从 100 开始定,前 100 是给框架预留的 / val id: Int / 处理器调用优先级,值越大优先级越大,最大为 100设置的时候注意查看现有的其他处理器的优先级,尽量不要重复 / @get:IntRange(from = 0L, to = 100L) val priority: Int / @param url 原始 url @param playItem 播放列表中的当前 item,如果没有列表则为空 @param playParam 播放参数 @param scope 协程作用域 @return 输出的结果 / suspend fun process(url: String?, playItem: PlayItem?, playParam: PlayParam?, scope: CoroutineScope): PlayerPreProcessResult}
播放器回调统一采用 kotlin dsl 的形式,简单示例如下:private val mPlayer = MEPlayer(this).apply { onReady { // 打开 url 资源成功回调 } onDuration { // 更新时长 } onPlayingStateChanged { isPlaying, from -> // 更新播放状态 } onPositionUpdate { // 更新播放进度 } onCompletion { // 播放结束 } onRetry { // 播放出错会自动调用 onRetry 进行重试,如果业务没有实现则跳转到 onError // onRetry 是一个 suspend 方法,可以进行耗时操作,需要返回一个 url,可以是 player.originUrl,也可以是请求后端返回的一个新 url } onError { // 错误处理 }}
MEPlayer 支持传入 LifecycleOwner,可以在 LifecycleOwner onDestroy 的时候自动释放构造方法为:/ 播放器构造方法,大多数场景都应该使用 MEPlayer,会跨进程播放 @param lifecycleOwner LifecycleOwner 对象,对于可以在退出页面后继续播放的场景,可以传 ProcessLifecycleOwner.get(),其他场景可以传页面的 LifecycleOwner @param from 用于在日志 tag 上显示业务来源,可以传页面的 TAG,默认使用 lifecycleOwner 所在页面的 className @param type 播放器类型,默认值为 PLAYER_TYPE_AUTO PLAYER_TYPE_AUTO -> 根据磁盘缓存键值对里 “player_type” 对应的值来选择播放器,如果是 “exo” 则使用 ExoPlayer, 如果是 “bbp” 则使用 BBP 播放器,默认使用 ExoPlayer PLAYER_TYPE_BB_PLAYER -> 使用 BBP 播放器 PLAYER_TYPE_EXO_PLAYER -> 使用 ExoPlayer @param scope 协程作用域,用于播放器对象里创建协程,管理协程生命周期,默认值为 lifecycleOwner.lifecycleScope /class MEPlayer @JvmOverloads constructor( lifecycleOwner: LifecycleOwner, from: String = lifecycleOwner.tagName(), @PlayerType type: String = PLAYER_TYPE_AUTO, scope: CoroutineScope = lifecycleOwner.lifecycleScope)
播放框架还支持多实例场景,配音秀和小梦乡场景都是无声视频配合音频一起播放的,所以跨进程播放的时候要支持多个实例同时播放先看下播放器的一段日志:// 音频// 播放进程I/ServicePlayer.Hypnosis.bbp.core1 onReadyI/ServicePlayer.Hypnosis.bbp.core1 onPlaying, needRequestFocus: trueI/ServicePlayer.Hypnosis.bbp.core1 updatePlaybackState, shouldShowInMediaSession: true, enableNotification: true, enableRating: false, enableLyric: false// 主进程I/MEPlayer.Hypnosis.bbp.core1 onReadyI/MEPlayer.Hypnosis.bbp.core1 updatePlayingState, isPlaying: true, reason: 1 (open), position: 12 (00:00), notifyCallback: true, notifyNotification: true// 视频// 播放进程I/ServicePlayer.HypnosisHomeFragment.bbp.core2 onReadyI/ServicePlayer.HypnosisHomeFragment.bbp.core2 onPlaying, needRequestFocus: falseI/ServicePlayer.HypnosisHomeFragment.bbp.core2 updatePlaybackState, shouldShowInMediaSession: false, enableNotification: false, enableRating: false, enableLyric: false// 主进程I/MEPlayer.HypnosisHomeFragment.bbp.core2 onReadyI/MEPlayer.HypnosisHomeFragment.bbp.core2 updatePlayingState, isPlaying: true, reason: 1 (open), position: 21 (00:00), notifyCallback: true,
可以看出,播放器日志采用了多级 TAG 结构,在播放框架的主流程的每一个类中,打印的日志都能直接看出当前打印日志时所在的类、业务、播放内核类型和内核实例索引播放器实例采用 SparseArrayCompat 来存储,主进程和播放进程保证实例索引的一一对应关系在列表视频播放过渡到播放页场景中,需要做到实例无缝过渡,框架里会把播放页实例的参数传递给列表的实例,然后释放原实例,整个过程播放是持续进行的播放器优化在网络连接上,ExoPlayer 官方已经支持了 Cronet,经过和多媒体部门、主站一起合作,bbp 也添加了 Cronet 支持,Cronet 是一个由 Google 开发的网络库,也是 Chrome 的网络栈,它提供了高性能和可靠的网络访问能力,支持 HTTP、HTTP/2 以及 HTTP/3 协议,在 HTTP/3 下,90% 的用户起播速度提升了 100ms 以上另外 ExoPlayer 的缓存支持其实并不友好,音频 APP 的一个必备功能就是在播放的时候会持续缓存完整个音频,同时进度条会更新缓存进度,但是要想用 ExoPlayer 直接实现这点,很难,业内一般是用 AndroidVideoCache (https://github.com/danikula/AndroidVideoCache)来实现的,并不优雅,这里我修改了部分 ExoPlayer 的源码,添加了支持,内容较长不好展开讲,可以参考另一篇博客 ExoPlayer 如何实现持续缓存以及缓存进度监听: https://juejin.cn/post/7261801999011938363音频焦点管理音频焦点在框架内自动申请和释放,业务只需要在初始化播放器时设置音频焦点类型和是否忽略焦点抢占(即和其他应用同时播放)即可player.run { audioFocusGain = AUDIO_FOCUS_GAIN_TRANSIENT ignoreFocusLoss = true}
在每个播放器实例中都会有焦点监听和处理,实际效果可以看视频(https://www.bilibili.com/video/BV1E94y1q7yD)后台播放优化在应用退到后台后,如果进程(包括主进程)不是前台进程,很可能会在几秒内被系统杀死那么就需要在播放的时候通过调用 startForeground(int id, Notification notification) 将播放进程设置为前台进程,前台进程需要绑定一个通知,退到后台后,可以发现播放进程的存活率明显提升,但是播一会儿你会发现,主进程没了就是说主进程和播放进程都需要设置为前台进程,但是产品需求上我们只有一个播放器通知,所以主进程要用和播放进程一样的通知内容开启前台进程,以保证用户切换音频的时候不会看到闪出一个非播放通知这里我们主进程也开了个通知服务来更新通知,播放进程只需要开启前台进程的时候绑定通知就好了,后续通知的更新交由主进程完成播放时退后台打印优先级可以看到两个进程都是较高的优先级> adb shell$ cat /proc/`pidof cn.missevan`/oom_adj3$ cat /proc/`pidof cn.missevan:player`/oom_adj3
还有一种情况是,主进程活着,但是播放进程被杀死了,或者播放进程出现问题崩溃了,这时候主进程需要恢复播放进程,不仅仅是启动进程,也需要维持原有的进度恢复播放,还需要创建新的通知开启前台进程这些步骤都需要拿到原有的数据,在播放进程存放这些数据不靠谱,所以主进程执行的步骤,都需要保存数据,以供播放进程重连后使用播放失败重试包含中途网络断开媒体数据却没有缓存完、链接失效、seek 失败、切换清晰度失败、音视频切换失败等场景,这些场景的重试逻辑是有所区分的,要保证代码逻辑清晰符合需求又没有重复代码是比较困难的,好在梳理异同点后把逻辑都聚合到了一块,对于后期扩展也比较友好这里通过 playType 区分场景,核心逻辑如下:val playParamApplier: PlayParam.() -> Unit = { // 重试的时候复用上次的参数 from(currentPlayParam) // 重试都是保持原来设置的 playWhenReady,即使原始请求是不要 keepPlayingState 的,重试也可以设为 true,因为原始请求已经生效了,重试就可以保持了 keepPlayingState = true isSwitchUrl = true stopPrevious = false isRetry = true // 针对有的错误,转换播放类型 when (errorCode) { PLAYER_ERROR_CODE_OPEN_FAILED -> { // 打开失败的情况直接按原来的参数重新打开即可,isSwitchUrl 要传 false,否则会没有 onReady、onDuration 回调 isSwitchUrl = false position = this@BaseMediaPlayer.position } PLAYER_ERROR_CODE_SEEK_FAILED -> { playType = PLAYER_PLAY_TYPE_SEEK_RETRY } PLAYER_ERROR_CODE_SWITCH_QUALITY_FAILED -> { // bbp 切换清晰度第一次出错以后会走到这里执行重试,重试需要换播放类型 playType = PLAYER_PLAY_TYPE_SWITCH_QUALITY_RETRY } }}
进入后台和离开视频页后暂停视频解码,需要设置对应视频容器所在页面的 LifecycleOwner,调用 videoPageLifecycleOwner = this@XXXFragment 即可,如果没有设置则会使用构造方法里的 LifecycleOwner在后台播放时使用 WifiLockManager 和 WakeLockManager 启用 Wi-Fi 锁和唤醒锁可以让应用在后台也能持续联网,保证播放的流畅性在国产的 ROM 里,要想在后台持续播放,保证应用运行的相关权限给够了才是最稳妥的,所以我们还加了个后台播放优化设置页,这个页面框架里不提供,需要业务自行实现通知栏和播控中心对于通知栏,业务上既有使用系统媒体通知样式的需求,也有使用自定义布局的需求,这些不同样式的通知,基本只有 UI 展示、按钮点击处理上的区别,其他通知逻辑是基本一致的,猫耳播放框架做到了业务只需要设置差异部分,其他 API 调用保持一致通知基础数据设置如下:// 音视频通知栏player.updateNotificationData { smallIcon = R.drawable.ic_player_notification actionList = arrayListOf( PLAYER_NOTIFICATION_ACTION_PLAY, PLAYER_NOTIFICATION_ACTION_PAUSE, PLAYER_NOTIFICATION_ACTION_PREVIOUS, PLAYER_NOTIFICATION_ACTION_NEXT, PLAYER_NOTIFICATION_ACTION_FAST_FORWARD, PLAYER_NOTIFICATION_ACTION_REWIND ) showActionsInCompactView = arrayListOf(1, 2, 3) contentAction = AppConstants.PLAY_ACTION contentClassName = MainActivity::class.java.name bizType = PLAYER_FROM_MAIN groupId = NotificationChannels.Play.groupId channelId = NotificationChannels.Play.channelId channelName = NotificationChannels.Play.channelName channelDesc = NotificationChannels.Play.channelDescription visibility = NotificationCompat.VISIBILITY_PUBLIC}// 直播通知栏updateNotificationData { smallIcon = R.drawable.ic_notification_small forceOngoing = true customLayout = R.layout.layout_notification_live_meplayer coverRadius = 4 defaultCover = R.drawable.notification_live_default_avatar contentAction = AppConstants.PLAY_ACTION contentClassName = MainActivity::class.java.name bizType = PLAYER_FROM_LIVE groupId = NotificationChannels.Live.groupId channelId = NotificationChannels.Live.channelId channelName = NotificationChannels.Live.channelName channelDesc = NotificationChannels.Live.channelDescription visibility = NotificationCompat.VISIBILITY_PUBLIC}
对于播控的适配主要是要考虑 MIUI、ColorOS 等国产 ROM 和鸿蒙的差异,除鸿蒙之外,基本按官方文档更新 MediaSession 即可,对于鸿蒙则要多一些适配,比如鸿蒙支持下图两种场景:这里面歌词、收藏、快进快退等逻辑都是需要根据不同的业务设置来处理的,目前业务只需要调用播放器对应的字段进行设置即可,使用比较简单总结本文介绍了猫耳FM在 Android 平台上开发媒体播放框架的实践经验,包括架构设计、核心技术、优化改进等方面希望通过这篇文章,能够给广大的 Android 开发者提供一些有用的参考和启发,也欢迎大家提出宝贵的意见和建议作者:李平发 - 哔哩哔哩资深开发工程师来源:微信公众号:哔哩哔哩技术出处:https://mp.weixin.qq.com/s/LdC1WRzgaLcLt5xDCosb9g(图片来源网络,侵删)
0 评论