RTC 腳手架的設計和實現

圖片來源:https://699pic.com/tupian-401703470.html
什麼是 RTC?
RTC即 Real-Time Communication 的簡稱是一種給行業提供高併發、低延時、高清流暢、安全可靠的全場景、全互動、全實時的音視訊服務的終端服務。上面是比較官方的解釋,通俗的來講就是一種能夠實現一對一、多對多音視訊通話等眾多功能的服務。目前提供該項服務的服務商有很多例如:聲網、雲信、火山引擎、騰訊雲等。
背景
目前雲音樂旗下 APP 眾多,其中涉及到 RTC 業務的不在少數,例如:常見的音視訊連麥、PK、派對房,1v1 聊天等。由於業務線不同,功能不同,開發者也不同,大家各寫一套,不斷的重複造輪子,因此為了避免重複的開發工作提升開發效率,需要有一套通用的RTC框架。
設計思路
在講具體的方案設計之前,先講一下我的設計思路:
-
功能內聚:需要將功能都封裝在一個容器裡,對外通過介面提供方法呼叫
-
業務隔離:不同的業務需要有不同的功能容器
-
統一呼叫:所有功能容器需要有統一的呼叫入口
-
狀態維護:需要對狀態進行精準維護
-
切換無感:進行功能容器切換時候,無感知
-
核心可控:對核心鏈路可監控,故障預警
基於以上 6 點,大致的架構設計如圖所示,這裡先不用深究圖中的模組表示什麼,後面會講到,這裡只是先了解一下大致的架構:

接下來我就來講講具體的實現過程。
方案設計
前言:
RTC 的業務場景雖然很多,但本質上卻相差無幾,都是使用者加入到一個共同的房間,然後在房間內進行實時的音視訊通訊。具體到實際專案中大致又可分為兩種:全場景 RTC 和部分場景 RTC。
-
全場景 RTC:整個業務都是通過 RTC 技術實現例如:1v1 音視訊通話、派對房等。
-
部分場景 RTC:即整個業務鏈路中只有一部分使用了 RTC 技術,往往這種業務會涉及到引擎的切換。
不管是哪一種場景,承載核心功能的引擎都是必不可少的,因此我們首先就從引擎開始著手,另外為了方便描述,後續便將引擎統一稱作 Player。
1、Player 的封裝
在與 RTC 相關聯的業務中會涉及到不同型別的 Player,例如:主播開播(推流 Player),觀眾觀看直播(拉流 Player)以及 RTC Player等。它們的功能雖然各不相同,但用法卻有相似之處,例如都有啟動 start,終止 stop 等。因此我們可以將不同的 Player 抽象出一個共同的介面 IPlayer 相關程式碼如下:
interface IPlayer<DS : IDataSource, CB : ICallback> {
fun start(ds: DS)
fun stop()
fun <T : Any> setParam(key: String, value: T?)
......
}
其中 IDataSource 和 ICallback 分別是啟動 Player 所需要的資料來源和回撥,後面的文章中也會多次提到,特別是 IDataSource 它是 Player 啟動的源頭就好比打電話時的電話號碼。
在這裡遇到的一個問題點就是由於 Player 內聚了所有的功能除了有一些通用方法外,也有著屬於自己特有的方法,例如:靜音,音量調節等。這些方法眾多而且各不相同無法在 IPlayer 介面中全部列出,即使能全部列出,但隨著業務的迭代 Player 中的方法肯定會不斷變化,不可能每更改一個方法就改一下介面,這顯然不符合程式設計原則。那麼如何將不同的方法抽象化,讓上層通過呼叫同一個方法來執行不同的操作呢?這裡通過:
fun <T : Any> setParam(key: String, value: T?)
來實現,其中 key 表示方法的唯一標記,value 表示方法的入參。這樣上層只需要通過呼叫 setParam 傳入相應的方法標記和方法入參即可呼叫到對應的方法了。那麼如何做到呢?答案也很簡單通過一箇中間層建立起一一對映關係。但是 Player 的型別眾多,要是每寫一個 Player 都要寫一個對映邏輯就太麻煩了。所以這裡通過 APT 編譯時註解再結合 javapoet 自動生成這個中間層並給它命名為 xxxPlayerWrapper 其內部生成一個 convert 方法,在這個方法內部完成一一對映邏輯。接下來我們看看具體實現過程:
-
首先定義了兩個註解分別作用於具體的 Player 和對應的方法例如:
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.TYPE})
public @interface PlayerClass {
}
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.METHOD})
public @interface PlayerMethod {
String name();
}
@PlayerClass
open class xxxPlayer : IPlayer<xxxDataSource, xxxCallback>() {
@PlayerMethod(name = "key1")
fun method1(v: String) {
....具體實現
}
}
-
一一對映關係建立:
xxxPlayer 和 xxxPlayerWrapper 之間是一個相互依賴關係,互為彼此的成員變數。當呼叫 xxxPlayer 的介面方法 setParam(key: String, value: T?) 時,會直接呼叫到 xxxPlayerWrapper 的 convert 方法,convert 方法會根據 key 來找到其所對應的方法名,最後直接呼叫到 Player 的具體方法。

由於所有的 Player 都有這個邏輯因此可以將這部分再抽象成一個 AbsPlayer:
abstract class AbsPlayer<DS : IDataSource, CB : ICallback>
: IPlayer<DS, CB>{
var dataSource: DS? = null
private val wrapper by lazy {
val ret = kotlin.runCatching {
val clazz = Class.forName(this::class.java.canonicalName + "Wrapper")
val signature = arrayOf(this::class.java)
clazz.constructors.find {
signature.contentEquals(it.parameterTypes)
}?.newInstance(this) as? PlayerWrapper
}
ret.exceptionOrNull()?.printStackTrace()
ret.getOrNull()
}
override fun <T : Any> setParam(key: String, value: T?) {
wrapper?.convert(key, value)
}
//...... 省略其他無關程式碼
}
最後整個 Player 的類圖如下所示:

這裡我們不關注 Player 的功能具體是如何實現的,比如如何推流,如何拉流,如何進行 RTC 等。畢竟每個專案底層所用的服務商 sdk 各不相同,技術實現也不同,因此這裡我們只從架構的層面去探討。
2、Player 的切換
Player 的切換針對的就是部分場景 RTC,這裡我們引入 SwitchablePlayer 的概念專門用於此種場景,而其本身也繼承自 AbsPlayer, 具備 Player 的所有功能。只不過這些功能是通過裝飾者模式由其內部真正的 Player 來實現,同時增加了 Switch 的能力。再講到 Switch 能力之前先來思考幾個問題。
-
何時觸發 Switch?
-
如何進行 Switch?
-
Switch 的目標物件 Player 從何而來?
第一個問題何時觸發 Switch:我們知道只要觸發 Switch 就意味著需要啟動另外的 Player,而啟動 Player 又需要上面提到的 IDataSource,因此我們只需要判斷啟動 Player 所傳入的 IDataSource 型別和當前 Player 的 IDataSource 型別是否相同,如果不同便可觸發。判斷的具體邏輯是對比當前 Player 泛型引數的 IDataSource 型別( AbsPlayer<DS : IDataSource, CB : ICallback>第一個範型引數 )和傳入的 IDataSource 型別來實現。
private fun isSourceMatch(
player: AbsPlayer<IDataSource, ICallback>?,
ds: IDataSource
): Boolean {
if (player == null) {
return false
} else {
val clazz = player::class.java
var type = getGenericSuperclass(clazz) ?: return false
while (Types.getRawType(type) != AbsPlayer::class.java) {
type = getGenericSuperclass(type) ?: return false
}
return if (type is ParameterizedType) {
val args = type.actualTypeArguments
if (args.isNullOrEmpty()) {
false
} else {
Types.getRawType(args[0]).isInstance(ds) && isSameSource(player, ds)
}
} else {
false
}
}
}
第二個問題如何進行 Switch:這個就比較簡單了只需要停止掉當前的 Player 再啟動目標 Player 即可。
第三個問題 Switch 的目標物件 Player 從何而來:SwitchablePlayer 並不清楚業務需要哪些 Player ,只是對 Player 功能的一層包裝以及維護 Switch 功能,因此具體的 Player 建立需要由業務層來實現, SwitchablePlayer 只提供一個獲取 Player 的抽象方法例如:
abstract fun getPlayer(ds: IDataSource): AbsPlayer<out IDataSource, out ICallback>?
另外由於進行 Switch 的時候會停止掉當前的 Player,而被停止的 Player 是否能複用,如果能複用則可以將其快取起來,下次使用優先從快取中獲得。整個SwitchablePlayer對應的流程如圖所示:

在使用時呼叫者可以根據自己的業務定義相關 Player,例如在直播-> PK 的業務中,涉及到兩個 Player 的切換即:LivePlayer 和 PKPlayer
class LivePKSwitchablePlayer : SwitchablePlayer(false) {
override fun getPlayer(ds: IDataSource): AbsPlayer<out IDataSource, out ICallback> {
return when (ds) {
is LiveDataSource -> {
LivePlayer()
}
is PKDataSource -> {
PKPlayer()
}
else -> LivePlayer()
}
}
}
3、流程封裝
對於整個 RTC 流程的封裝需要搞清楚兩件事情:
-
RTC 的主體流程是怎樣的
-
業務呼叫方需要的是什麼,關注的又是什麼
由於 RTC 的主體流程和日常打電話相似,所以筆者以此類比,這樣大家更容易理解。下圖所示即為整個通話過程。
搞清楚整個流程後,接下來就是搞清楚第二件事情,業務呼叫方需要的是什麼,關注的又是什麼。結合上圖來看關注的大概有三點:
-
第一就是需要具備撥打和結束通話的入口;( Player 的 Start 和 Stop )
-
第二就是要能知道當前的通話狀態比如是否正在連通,是否已經接通,是否通話結束;( Player 的 狀態維護 )
-
第三就是一些反饋比如對方未接通,對方不在服務區,手機號是空號等。( Player 的 核心事件回撥即之前提到的 ICallback )
而至於它是如何連通的,底層做了哪些操作,撥打電話的人對此毫不關心。基於上述我們的整體功能設計所要關注的點就有了。
1、通過設計一個 manager 來管理 Player 並對外暴露 Start 和 Stop 方法。 2、對 Player 進行狀態維護,並讓其狀態可被上層監聽。 3、Player 的一些核心事件回撥也可被上層監聽。
其中第一點和第三點比較簡單,這裡就不做過多的贅述。第二點狀態維護,筆者使用了 StateMachine 狀態機來實現,在不同的狀態執行不同的操作,同時每一種狀態都對應一個狀態碼,上層可以通過監聽狀態碼來感知狀態變化。

狀態碼和核心事件的設定這裡使用了 LiveData 去處理
class RtcHolder : IRtcHolder {
private val _rtcState = MutableLiveData(RtcStatus.IDLE)
private val _rtcEvent = MutableLiveData(RtcEvent.IDLE)
val rtcState = _rtcState.distinctUntilChanged()
val rtcEvent = _rtcEvent.distinctUntilChanged()
private val callBack = object : IRtcCallBack {
override fun onCurrentStateChange(stateCode: Int) {
_rtcState.value = stateCode
}
override fun onEvent(eventCode: Int) {
_rtcEvent.value = eventCode
}
//......省略其他程式碼
}
init {
//上層狀態監聽
rtcState.observeForever {
when (it) {
RtcStatus.CONNECT_END -> {
ToastHelper.showToast("通話結束")
}
}
}
}
//......省略其他程式碼
}
到這裡整個腳手架的方案設計就結束了,其中服務商 SDK 封裝部分以及監控部分,筆者準備放到下期再來講解。
總結
本文介紹了 RTC 腳手架產生的背景,並以通俗易懂的方式一步步闡述設計過程以及最終實現。在此期間發現問題,解決問題,引出思考。由於受限於篇幅,不能將每一個點都進行詳盡的介紹,有興趣的同學如有疑問,可以留言,一起探討學習。
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!
- 不一樣的Android堆疊抓取方案
- 雲音樂 Swift 混編 Module 化實踐
- iOS雲音樂APM效能監控實踐
- 雲音樂iOS端程式碼靜態檢測實踐
- 網易雲音樂全面開源一款雲原生應用部署平臺:Horizon
- dex 優化編年史
- 如何實現 iOS 16 帶來的 Depth Effect 圖片效果
- 雲音樂 iOS 跨端快取庫 - NEMichelinCache
- 雲音樂 Android 記憶體監控探索篇
- Android APP 出海實踐
- Android 除錯實戰與原理詳解
- 社交場景下iOS訊息流互動層實踐
- 你構建的程式碼為什麼這麼大
- 扒一扒 Jetpack Compose 實現原理
- Recoil 狀態管理方案的淺入淺出
- 基於自建 VTree 的全鏈路埋點方案
- 雲音樂 iOS 啟動效能優化「開荒篇」
- 雲音樂播放頁直播推薦實戰
- 雲音樂iOS端網路圖片下載優化實踐
- 雲音樂 iOS 啟動效能優化「開荒篇」