概述

猫耳FM是我国最大的 95 后声响内容分享平台,是B站重要平台之一,深度合作国内尖端声优工作室,打造了数百部精品广播剧,全站播映总量超过百亿次。

猫耳 Android 播映结构开发实践

MEPlayer 是猫耳 Android 技能团队研发的一款适用于音视频、直播、特效播映等多种场景的跨进程播映结构。现在支撑:

  • 音视频、直播、特效播映。
  • 支撑自定义播映内核,现在内置了 exo、bbp(多媒体部分开发的轻量级播映内核),都添加了边下边播支撑,可以自行扩展支撑 ijk 等内核,完成固定的几个接口即可。
  • 与事务彻底解耦,已在公司内多个团队运用。
  • 跨进程播映音视频、直播、特效,播映进程被杀后主动康复。
  • 主动办理音频焦点,支撑疏忽焦点抢占(与其他音视频运用一同播映)。
  • 支撑显现在告诉栏和播控中心,已适配包含鸿蒙(猫耳FM 现已过华为测验,配置在鸿蒙体系源码白名单里)在内的国内体系。
  • 后台播映优化:bbp 后台播视频支撑暂停视频解码;播映时后台保持网络衔接;能一同提高主进程和播映进程优先级确保运用播映时存活更久。
  • 支撑切换明晰度、播映半途音视频互切、起播出错或者播映半途出错主动重试。
  • 根底功用:播映列表、循环形式、进展跳转、快进、快退、倍速播映、设置音量、越过片头片尾、定时暂停、播映完整首暂停等。

详细运用场景可以参阅猫耳FM APP:

音视频主播映场景: 音视频播映页;

直播、特效: 直播间;

短视频场景: 主页引荐 -> 小梦乡;

列表播映以及过渡到播映页场景: 主页引荐 -> 播映大卡;

配音秀: 发现 -> 活动 -> 配音活动;

单个音视频、特效播映场景: 个人主页头像音、主页点击盲盒剧场、活动 -> 运势语音、主页声响恋人 tab 下的引荐 UP 主播映、我的 -> 发动音等。

来历

旧版本猫耳FM APP 内的很多的音视频播映场景,运用了 ijk、ExoPlayer、MediaPlayer 等多种播映器计划,且播映逻辑和事务逻辑高度耦合,当播映场景呈现新的需求,改动本钱巨大,且编写需求代码的进程中易产生 bug;原播映场景相关的代码短少模块化,代码复用程度低,从而影响后期保护。因而,项目迫切需求一套共同的播映结构,以满足不同场景的需求。调研了主流的播映结构之后,发现很难一同满足咱们的多样化场景。在调研了主流的播映结构后,发现没有现成计划可以满足项目的多样化场景,于是咱们开发了 MEPlayer,0 重复逻辑、0 事务耦合,API 友爱,开发的了解和接入本钱都极小。

播映器流程

下图是一个简略的播映流程图。

猫耳 Android 播映结构开发实践

MEPlayer、MEDirectPlayer 是音视频和直播事务直接触摸的两个播映器进口,MEPlayer 支撑跨进程播映,MEDirectPlayer 则直接在主进程播映,这两个 Player 的根底 API 和播映逻辑代码都是同享的,差异部分在于播映器进口实例和内核封装 Player 的衔接,比较于 MEPlayer,MEDirectPlayer 短少衔接播控中心的才能。

为什么需求 MEDirectPlayer 呢?由于关于闪屏、发动音等在发动 APP 一两秒内就要播映的场景,跨进程播映是来不及的,可能会呈现需求播的时分进程还没衔接好的状况。而跨进程部分逻辑是比较复杂的,所以仍是分离一个播映器进口关于后期保护和事务了解都更友爱。

关于视频和特效播映,需求绑定视频/特效容器的 Surface,SurfaceListener 是在播映器内部办理的,事务只需求传递容器 View 给播映结构即可,现在支撑 TextureView 和 SurfaceView,事务假如设置过 SurfaceListener,结构里也会兼容,在对应办法回调时,会给老的 listener 一同回调,在列表场景视频卡片切换时,会把事务设置的 listener 还给上一个卡片。特效播映比较特殊,播映器进口是 AlphaVideoPlayer,用到的播映内核 API 也不一样,在跨进程 AIDL 调用中都是独立的办法,可是事务调用的 API 跟音视频播映是共同的。

播映结构的状况机见下图:

猫耳 Android 播映结构开发实践

起播处理流程选用的拦截器形式,关于全局的 https、免流处理等操作,可以自定义一个拦截器注入到播映器中,关于列表播映中某一条 item 没有 url 信息时,也可以在默许的拦截器回调中请求接口回来一个新的 url 来播映。

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
}

猫耳 Android 播映结构开发实践

播映器回调共同选用 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 onReady
I/ServicePlayer.Hypnosis.bbp.core1 onPlaying, needRequestFocus: true
I/ServicePlayer.Hypnosis.bbp.core1 updatePlaybackState, shouldShowInMediaSession: true, enableNotification: true, enableRating: false, enableLyric: false
// 主进程
I/MEPlayer.Hypnosis.bbp.core1 onReady
I/MEPlayer.Hypnosis.bbp.core1 updatePlayingState, isPlaying: true, reason: 1 (open), position: 12 (00:00), notifyCallback: true, notifyNotification: true
// 视频
// 播映进程
I/ServicePlayer.HypnosisHomeFragment.bbp.core2 onReady
I/ServicePlayer.HypnosisHomeFragment.bbp.core2 onPlaying, needRequestFocus: false
I/ServicePlayer.HypnosisHomeFragment.bbp.core2 updatePlaybackState, shouldShowInMediaSession: false, enableNotification: false, enableRating: false, enableLyric: false
// 主进程
I/MEPlayer.HypnosisHomeFragment.bbp.core2 onReady
I/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 以上。

猫耳 Android 播映结构开发实践

另外 ExoPlayer 的缓存支撑其实并不友爱,音频 APP 的一个必备功用就是在播映的时分会继续缓存完整个音频,一同进展条会更新缓存进展,可是要想用 ExoPlayer 直接完成这点,很难,业内一般是用 AndroidVideoCache 来完成的,并不优雅,这儿我修改了部分 ExoPlayer 的源码,添加了支撑,内容较长不好展开讲,可以参阅另一篇博客 ExoPlayer 如何完成继续缓存以及缓存进展监听。

音频焦点办理

音频焦点在结构内主动申请和开释,事务只需求在初始化播映器时设置音频焦点类型和是否疏忽焦点抢占(即和其他运用一同播映)即可。

player.run {
    audioFocusGain = AUDIO_FOCUS_GAIN_TRANSIENT
	ignoreFocusLoss = true
}

在每个播映器实例中都会有焦点监听和处理,实际效果可以看视频(www.bilibili.com/video/BV1E9…):

player.bilibili.com/player.html…

后台播映优化

猫耳 Android 播映结构开发实践

在运用退到后台后,假如进程(包含主进程)不是前台进程,很可能会在几秒内被体系杀死。那么就需求在播映的时分经过调用 startForeground(int id, Notification notification) 将播映进程设置为前台进程,前台进程需求绑定一个告诉,退到后台后,可以发现播映进程的存活率明显提高,可是播一瞬间你会发现,主进程没了。就是说主进程和播映进程都需求设置为前台进程,可是产品需求上咱们只要一个播映器告诉,所以主进程要用和播映进程一样的告诉内容开启前台进程,以确保用户切换音频的时分不会看到闪出一个非播映告诉。这儿咱们主进程也开了个告诉服务来更新告诉,播映进程只需求开启前台进程的时分绑定告诉就好了,后续告诉的更新交由主进程完成。播映时退后台打印优先级可以看到两个进程都是较高的优先级。

> adb shell
$ cat /proc/`pidof cn.missevan`/oom_adj
3
$ cat /proc/`pidof cn.missevan:player`/oom_adj
3

还有一种情况是,主进程活着,可是播映进程被杀死了,或者播映进程呈现问题崩溃了,这时分主进程需求康复播映进程,不仅仅是发动进程,也需求维持原有的进展康复播映,还需求创建新的告诉开启前台进程。这些过程都需求拿到原有的数据,在播映进程寄存这些数据不靠谱,所以主进程履行的过程,都需求保存数据,以供播映进程重连后运用。

猫耳 Android 播映结构开发实践

播映失利重试包含半途网络断开媒体数据却没有缓存完、链接失效、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。在后台播映时运用 WifiLockManagerWakeLockManager 启用 Wi-Fi 锁和唤醒锁可以让运用在后台也能继续联网,确保播映的流畅性。

在国产的 ROM 里,要想在后台继续播映,确保运用运转的相关权限给够了才是最稳妥的,所以咱们还加了个后台播映优化设置页,这个页面结构里不供给,需求事务自行完成。

猫耳 Android 播映结构开发实践

告诉栏和播控中心

猫耳 Android 播映结构开发实践

关于告诉栏,事务上既有运用体系媒体告诉款式的需求,也有运用自定义布局的需求,这些不同款式的告诉,根本只要 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 即可,关于鸿蒙则要多一些适配,比方鸿蒙支撑下图两种场景:

猫耳 Android 播映结构开发实践

这儿面歌词、保藏、快进快退等逻辑都是需求依据不同的事务设置来处理的,现在事务只需求调用播映器对应的字段进行设置即可,运用比较简略。

总结

本文介绍了猫耳FM在 Android 平台上开发媒体播映结构的实践经验,包含架构设计、核心技能、优化改进等方面。期望经过这篇文章,可以给广阔的 Android 开发者供给一些有用的参阅和启示,也欢迎大家提出名贵的意见和建议。