猫耳 Android 播放框架开发实践

6,876 阅读13分钟

概述

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

编组@1x.png

MEPlayer 是猫耳 Android 技术团队研发的一款适用于音视频、直播、特效播放等多种场景的跨进程播放框架。目前支持:

  • 音视频、直播、特效播放。
  • 支持自定义播放内核,目前内置了 exo、bbp(多媒体部门开发的轻量级播放内核),都添加了边下边播支持,可以自行扩展支持 ijk 等内核,实现固定的几个接口即可。
  • 与业务完全解耦,已在公司内多个团队使用。
  • 跨进程播放音视频、直播、特效,播放进程被杀后自动恢复。
  • 自动管理音频焦点,支持忽略焦点抢占(与其他音视频应用同时播放)。
  • 支持显示在通知栏和播控中心,已适配包括鸿蒙(猫耳FM 已通过华为测试,配置在鸿蒙系统源码白名单里)在内的国内系统。
  • 后台播放优化:bbp 后台播视频支持暂停视频解码;播放时后台保持网络连接;能同时提升主进程和播放进程优先级保证应用播放时存活更久。
  • 支持切换清晰度、播放中途音视频互切、起播出错或者播放中途出错自动重试。
  • 基础功能:播放列表、循环模式、进度跳转、快进、快退、倍速播放、设置音量、跳过片头片尾、定时暂停、播放完整首暂停等。

具体使用场景可以参考猫耳FM APP:

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

直播、特效: 直播间;

短视频场景: 首页推荐 -> 小梦乡;

列表播放以及过渡到播放页场景: 首页推荐 -> 播放大卡;

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

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

起源

旧版本猫耳FM APP 内的大量的音视频播放场景,使用了 ijk、ExoPlayer、MediaPlayer 等多种播放器方案,且播放逻辑和业务逻辑高度耦合,当播放场景出现新的需求,改动成本巨大,且编写需求代码的过程中易产生 bug;原播放场景相关的代码缺少模块化,代码复用程度低,进而影响后期维护。因此,项目迫切需要一套统一的播放框架,以满足不同场景的需要。调研了主流的播放框架之后,发现很难同时满足我们的多样化场景。在调研了主流的播放框架后,发现没有现成方案能够满足项目的多样化场景,于是我们开发了 MEPlayer,0 重复逻辑、0 业务耦合,API 友好,开发的理解和接入成本都极小。

播放器流程

下图是一个简单的播放流程图。

MEPlayer、MEDirectPlayer 是音视频和直播业务直接接触的两个播放器入口,MEPlayer 支持跨进程播放,MEDirectPlayer 则直接在主进程播放,这两个 Player 的基础 API 和播放逻辑代码都是共享的,差异部分在于播放器入口实例和内核封装 Player 的连接,相比于 MEPlayer,MEDirectPlayer 缺少连接播控中心的能力。

为什么需要 MEDirectPlayer 呢?因为对于闪屏、启动音等在启动 APP 一两秒内就要播放的场景,跨进程播放是来不及的,可能会出现需要播的时候进程还没连接好的状况。而跨进程部分逻辑是比较复杂的,所以还是分离一个播放器入口对于后期维护和业务理解都更友好。

对于视频和特效播放,需要绑定视频/特效容器的 Surface,SurfaceListener 是在播放器内部管理的,业务只需要传递容器 View 给播放框架即可,目前支持 TextureView 和 SurfaceView,业务如果设置过 SurfaceListener,框架里也会兼容,在对应方法回调时,会给老的 listener 同时回调,在列表场景视频卡片切换时,会把业务设置的 listener 还给上一个卡片。特效播放比较特殊,播放器入口是 AlphaVideoPlayer,用到的播放内核 API 也不一样,在跨进程 AIDL 调用中都是独立的方法,但是业务调用的 API 跟音视频播放是一致的。

播放框架的状态机见下图:

起播处理流程采用的拦截器模式,对于全局的 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
}

播放器回调统一采用 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 以上。

另外 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…

后台播放优化

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

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

还有一种情况是,主进程活着,但是播放进程被杀死了,或者播放进程出现问题崩溃了,这时候主进程需要恢复播放进程,不仅仅是启动进程,也需要维持原有的进度恢复播放,还需要创建新的通知开启前台进程。这些步骤都需要拿到原有的数据,在播放进程存放这些数据不靠谱,所以主进程执行的步骤,都需要保存数据,以供播放进程重连后使用。

播放失败重试包含中途网络断开媒体数据却没有缓存完、链接失效、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 里,要想在后台持续播放,保证应用运行的相关权限给够了才是最稳妥的,所以我们还加了个后台播放优化设置页,这个页面框架里不提供,需要业务自行实现。

通知栏和播控中心

对于通知栏,业务上既有使用系统媒体通知样式的需求,也有使用自定义布局的需求,这些不同样式的通知,基本只有 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 开发者提供一些有用的参考和启发,也欢迎大家提出宝贵的意见和建议。