RTC 腳手架的設計和實現

語言: CN / TW / HK
題圖

圖片來源:http://699pic.com/tupian-401703470.html

什麼是 RTC?

RTC即 Real-Time Communication 的簡稱是一種給行業提供高併發、低延時、高清流暢、安全可靠的全場景、全互動、全實時的音視訊服務的終端服務。上面是比較官方的解釋,通俗的來講就是一種能夠實現一對一、多對多音視訊通話等眾多功能的服務。目前提供該項服務的服務商有很多例如:聲網、雲信、火山引擎、騰訊雲等。

背景

目前雲音樂旗下 APP 眾多,其中涉及到 RTC 業務的不在少數,例如:常見的音視訊連麥、PK、派對房,1v1 聊天等。由於業務線不同,功能不同,開發者也不同,大家各寫一套,不斷的重複造輪子,因此為了避免重複的開發工作提升開發效率,需要有一套通用的RTC框架。

設計思路

在講具體的方案設計之前,先講一下我的設計思路:

  1. 功能內聚:需要將功能都封裝在一個容器裡,對外通過介面提供方法呼叫

  2. 業務隔離:不同的業務需要有不同的功能容器

  3. 統一呼叫:所有功能容器需要有統一的呼叫入口

  4. 狀態維護:需要對狀態進行精準維護

  5. 切換無感:進行功能容器切換時候,無感知

  6. 核心可控:對核心鏈路可監控,故障預警

基於以上 6 點,大致的架構設計如圖所示,這裡先不用深究圖中的模組表示什麼,後面會講到,這裡只是先了解一下大致的架構:

image.png

接下來我就來講講具體的實現過程。

方案設計

前言:

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?)

......
}

其中 IDataSourceICallback 分別是啟動 Player 所需要的資料來源和回撥,後面的文章中也會多次提到,特別是 IDataSource 它是 Player 啟動的源頭就好比打電話時的電話號碼。

在這裡遇到的一個問題點就是由於 Player 內聚了所有的功能除了有一些通用方法外,也有著屬於自己特有的方法,例如:靜音,音量調節等。這些方法眾多而且各不相同無法在 IPlayer 介面中全部列出,即使能全部列出,但隨著業務的迭代 Player 中的方法肯定會不斷變化,不可能每更改一個方法就改一下介面,這顯然不符合程式設計原則。那麼如何將不同的方法抽象化,讓上層通過呼叫同一個方法來執行不同的操作呢?這裡通過:

  fun <T : Any> setParam(key: String, value: T?)

來實現,其中 key 表示方法的唯一標記,value 表示方法的入參。這樣上層只需要通過呼叫 setParam 傳入相應的方法標記和方法入參即可呼叫到對應的方法了。那麼如何做到呢?答案也很簡單通過一箇中間層建立起一一對映關係。但是 Player 的型別眾多,要是每寫一個 Player 都要寫一個對映邏輯就太麻煩了。所以這裡通過 APT 編譯時註解再結合 javapoet 自動生成這個中間層並給它命名為 xxxPlayerWrapper 其內部生成一個 convert 方法,在這個方法內部完成一一對映邏輯。接下來我們看看具體實現過程:

  1. 首先定義了兩個註解分別作用於具體的 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) {
....具體實現
}
}
  1. 一一對映關係建立:

xxxPlayer 和 xxxPlayerWrapper 之間是一個相互依賴關係,互為彼此的成員變數。當呼叫 xxxPlayer 的介面方法 setParam(key: String, value: T?) 時,會直接呼叫到 xxxPlayerWrapper 的 convert 方法,convert 方法會根據 key 來找到其所對應的方法名,最後直接呼叫到 Player 的具體方法。

image.png

由於所有的 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 的類圖如下所示:

image.png

這裡我們不關注 Player 的功能具體是如何實現的,比如如何推流,如何拉流,如何進行 RTC 等。畢竟每個專案底層所用的服務商 sdk 各不相同,技術實現也不同,因此這裡我們只從架構的層面去探討。

2、Player 的切換

Player 的切換針對的就是部分場景 RTC,這裡我們引入 SwitchablePlayer 的概念專門用於此種場景,而其本身也繼承自 AbsPlayer, 具備 Player 的所有功能。只不過這些功能是通過裝飾者模式由其內部真正的 Player 來實現,同時增加了 Switch 的能力。再講到 Switch  能力之前先來思考幾個問題。

  1. 何時觸發 Switch?

  2. 如何進行 Switch?

  3. 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對應的流程如圖所示:

image.png

在使用時呼叫者可以根據自己的業務定義相關 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 流程的封裝需要搞清楚兩件事情:

  1. RTC 的主體流程是怎樣的

  2. 業務呼叫方需要的是什麼,關注的又是什麼

由於 RTC 的主體流程和日常打電話相似,所以筆者以此類比,這樣大家更容易理解。下圖所示即為整個通話過程。

搞清楚整個流程後,接下來就是搞清楚第二件事情,業務呼叫方需要的是什麼,關注的又是什麼。結合上圖來看關注的大概有三點:

  • 第一就是需要具備撥打和結束通話的入口;( Player 的 Start 和 Stop

  • 第二就是要能知道當前的通話狀態比如是否正在連通,是否已經接通,是否通話結束;( Player 的 狀態維護

  • 第三就是一些反饋比如對方未接通,對方不在服務區,手機號是空號等。( Player 的 核心事件回撥即之前提到的 ICallback

而至於它是如何連通的,底層做了哪些操作,撥打電話的人對此毫不關心。基於上述我們的整體功能設計所要關注的點就有了。

1、通過設計一個 manager 來管理 Player 並對外暴露 Start 和 Stop 方法。
2、對 Player 進行狀態維護,並讓其狀態可被上層監聽。
3、Player 的一些核心事件回撥也可被上層監聽。

其中第一點和第三點比較簡單,這裡就不做過多的贅述。第二點狀態維護,筆者使用了 StateMachine 狀態機來實現,在不同的狀態執行不同的操作,同時每一種狀態都對應一個狀態碼,上層可以通過監聽狀態碼來感知狀態變化。

image.png

狀態碼和核心事件的設定這裡使用了 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!