图片起源:https://699pic.com/tupian-401…
作者:AirLand
什么是 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](https://github.com/square/jav…
) 主动生成这个中间层并给它命名为 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!