貓耳 Android 播放框架開發實踐

本期作者

李平發

嗶哩嗶哩資深開發工程師

概述

貓耳 FM 是中國最大的 95 後聲音內容分享平臺,是 B 站重要平臺之一,深度合作國內頂級聲優工作室,打造了數百部精品廣播劇,全站播放總量超過百億次。

圖片

MEPlayer 是貓耳 Android 技術團隊研發的一款適用於音視頻、直播、特效播放等多種場景的跨進程播放框架。目前支持:

具體使用場景可以參考貓耳 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 (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_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。在後臺播放時使用 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 開發者提供一些有用的參考和啓發,也歡迎大家提出寶貴的意見和建議。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LdC1WRzgaLcLt5xDCosb9g