直播中台iLiveSDK终端框架演变之路
一、直播中台的诞生背景
- 疫情期间,直播带货火爆全网,直播能力成为各大业务急需的能力
2. 公司内业务平台发展到一定的用户量后积累了一定量的平台UGC、PGC,需要借助直播能力利用平台的生产和消费者提升变现能力。部门之前的Now直播产品具有丰富的直播运营变现的经验(19年50亿流水规模)及技术经验,能够帮助公司其他产品快速搭建直播生态链。
3. 部门内直播产品在不断衍生孵化,产品趋于多元化,产品业务形态不一样,但模块,技术很多是可以复用的,在这个背景下,我们的技术栈急需统一,避免重复造轮子。
这样的大环境下,直播中台建设迫在眉睫。
二、直播中台SDK的前期调研与分析
1、直播中台具备什么能力?终端SDK的定位是什么?
直播中台会提供一整套直播能力,包括:登录、开播、观看、房间内互动、对公管理、管理平台、商业化等。后台会提供一套完善的PASS服务。
终端SDK的定位是对直播中台PAAS服务进行易用性封装,为业务方提供端到端的直播服务。如下:
2、终端SDK需要具备什么能力?对外是怎样的形式?
SDK全称software development kit,软件开发工具包。传统SDK对外提供的基本都是一套API接口。SDK相当于一个虚拟的程序包,在这个程序包中有一份做好的软件功能,这份程序包几乎是全封闭的,只有一个小小接口可以联通外界。
这样的形式显然是不适用直播中台SDK的:
- 中台是一个具备全产品能力的SDK,包含一整套产品闭环的UI,逻辑,数据,基础能力;
- 当业务方只想接入直播中台的部分功能,比如只接入电商模块,只接入礼物模块等,这意味着一个全量的SDK对业务方是累赘的,并且最外层接口是不可用的;
- 一些业务方只想从数据层接入,不使用中台默认的UI和逻辑实现。或者只从逻辑层接入,自己实现UI或者嵌入业务原有的页面。
这些要求我们的SDK是个足够开放性的SDK
并且满足以下特性:
- 一键接入的能力,迅速搭建直播能力
- 化整为零的能力,满足个性化的单功能接入,部分模块接入侧,不同层次接入
直播SDK既然是一个业务非常丰富,基础模块非常多的SDK,那么和接入方自己的业务及基础能力大概率是有一定重合性的,并且业务方会存在很多定制需求。经过对接入方的前期调研,我们发现几乎所有模块都可能需要被定制。
例如:
- 业务方都有自己的UI风格,中台默认界面很多是不满足业务需求的,业务需要更改UI;
- 在直播场景需要展示一些业务自己的数据信息,比如人气值,走业务方自己的关注链体系等;
- 逻辑需要更改,比如在线人数的计算方式,业务自己有写算法逻辑,替换SDK的默认逻辑;
- 基础能力如网络监听,数据上报等能力业务方需要换成自己已有的;
- 基础库如:播放器、图片库、下载库业务需要换成自己已有的。
如下图:
三、中台SDK框架面临的困难和挑战
经过前期的调研和分析,基本确定了整个SDK的设计目标。
针对上述复杂的业务接入模式,和各种业务不同的接入定制需求,对整个终端SDK框架设计是个非常大的挑战。
这要求我们在功能完善的前提下,整个框架足够健全,足够灵活,足够开放。
如同一个装修好的房子一样:既有一个完整的功能体验,里面的家具是相互搭配的,但每个家具又是相互独立的,可以灵活组装,每个家具可以单独使用,也可以针对其中的某个家具进行替换或者维修翻新,又可以引入新的家具或者移除已有的家具。
如上述的关键词,这要求我们的SDK满足以下特性:
四、SDK框架方案探索
1、页面层级的高粒度业务模块拆分
因为模块可复用要求非常高,首先我们对所有页面按照功能特性进行拆分,如下图,细化到icon级别。
每个页面都是模块化的页面,不仅仅是按某一功能区分,更要对一个功能内进行更细致的拆分,更小粒度的拆分。
然后我们对这些拆分后的模块进行自由组装,能够轻松组装成我们不同的直播类型页面,如下图:
拆分后,我们的目的是:
- 不同页面按需使用模块组装
- 模块灵活复用,灵活插拔,业务灵活增减功能模块
2、业务模块内的精细化组件拆分
进行模块拆分后还是远远不够的,一个模块内部是包含UI,逻辑,数据的。
那么面临的问题又来了,可能接入方要重绘UI数据使用中台的,或者只想复用UI,数据使用自己的。
我们先看下原来now一个单模块是如何设计的:
很明显可以看出来,是一个MVC结构。
这个本身在Now的设计里是没有问题的,因为now是个独立的产品,只需模块间比较清晰独立,内部有一定分层,有一定扩展性即可。
但是放在中台问题就来了,因为我们面临的是业务复杂的定制需求,如下图,礼物面板业务方UI需要微调。
因为UI,逻辑,数据之间有一定的耦合性,当我们只想更改UI时,我们整个模块面临着重写,复用性非常差。
对此,我们需要的是细化职能,我们进行第2次拆分:抽象组件。
首先拆分成2大类组件:UI组件,服务组件。并且全部接口化,UI组件和服务组件之间通过胶水逻辑来衔接。
组件拆分的原则是:
1、组件mock数据后可单独运行
2、组件可独立复用和替换
拆分后组件对外只有接口,全部实行接口依赖,这样我们的UI组件,数据服务都是可以单独被复用,重写和替换的。
3、基础模块何去何从
中台内有很多基础模块和引入的第三方库,常用做法是我们一般会对基础功能封一层Util,使用到第三方的模块对第三方库进行implements。
针对以下几点进行考虑,问题又来了
1、大部分第三方库接入方是有的-> 三方库引入冲突
2、部分Util业务有,如Log ,系统api接口 -> 功能累赘
3、怎样最大化减包,怎样资源利用最大化
为此,我们决定all in component,也就是中台的所有模块都组件化,包括基础功能和第三方库,我们加入了2种组件:基础功能组件和第三方库包装组件,这2类组件都归类到服务组件。
这些基础功能也全部接口化,所有的基础功能和业务组件一样都需要可以支持到复用、重写、替换和去除。
4、组件标准化
前面提到,组件是分为接口和实现的,我们不仅要做到组件级别的复用,更要做到接口级别,实现级别的单一复用。
因为当某一个功能实现改变时,接口是可以复用的,实现如果可以去除掉,将得到最大程度的减包。
为此,我们将一个组件的接口和实现全部独立lib工程,接口与实现隔离,如下图:
5、组件动态化
所有组件,不能直接被业务模块构造,必须通过中台统一的工厂派发到对应的ComponentBuilder来构造,ComponentBuilder支持外部替换成自己的Builder,从而实现可动态替换组件的能力。
伪代码如下:
//创建默认的内部组件
ComponentFactory.buildDefault(AComponent.class);
//支持外部设置一个组件自己的构造器,时间动态替换内部组件
ComponentFactory.hook(AComponent.class, customBuilder);
//支持外部添加一个组件自己的构造器,时间动态新增内部组件
ComponentFactory.add(AComponent.class, customBuilder);
//创建一个组件,默认使用默认的Builder构造,如果被hook,使用hook的builder来创建
ComponentFactory.build(AComponent.class)
6、组件解耦机制
针对中台需要的框架模式,我们针对两方面进行思考与设计:
1、组件之间怎样实现零耦合,解除后如何通信?
2、每个组件都有能单独被业务方引入的诉求,组件怎样能够单独抽离可用?
目前业界有一些主流的解耦机制:
- 首先看下Arouter的模型
Arouter中的IProvider是比较典型的接口解耦的方式,如果中台的组件来套用的话,一般流程是:将组件的接口下沉到base通用层,下沉后,接口方法和数据对象彼此可见,在注册服务后,组件就可以使用彼此的功能了。这种形式比较方便简单的,而且更新方法和数据对象之后,可以通过报错的形式被通知,保证安全。
- 再来看下Redux模型
redux不是严格意义上的为组件之间解耦的框架。Redux的核心思想是数据驱动,通过数据和事件将view和业务流程解耦,将不同的业务流程相互解耦。以应用的数据为应用的核心,通过事件产生数据变化,通过数据驱动view的展示。
这种思想其实也是可以应用到终端的,各组件对数据中心关心的数据进行监听,通过数据驱动来解耦。
可以看到这2个框架模型其实有个共性,就是中心化:接口中心和数据中心
如果中台使用接口下沉的方式,面临2个问题:
1、中台拆分后组件有将近100个,这么多组件接口以及相应的数据结构如果下沉到base,通用层会非常膨胀,将完全不利于组件的单独抽离使用。
2、模块依赖能力与组件接口提供不是完全匹配的
那么 redux这种数据驱动呢?
如果全部使用通用化数据结构是可以的,如传json,map这种字典,通用化数据结构在前端是应用比较成熟的。如果放在中台呢? 对业务方及共建者将是个易用性极差的存在,因为每个组件都可能要被业务方单独拿出去二次开发和替换的,字典的可维护性是相当差的。如果不用通用化数据结构呢,那么又面临前面一样的问题,数据结构需要下沉,base膨胀。
还有一个问题:
如果某个组件是对安全性要求较高的,它的部分功能可能是不希望随便对其他部分可见的,这个时候显然下沉不是一个好的选择。
1、中心化不适用中台;
2、业务之间的接口依赖也是不适用中台的,因为每个业务模块或者业务组件都可能被单独抽离使用,如果A依赖B的接口,难道A被拿出去时还要带着B的接口吗?
为此,我们开发了更适用于中台的解耦模式:胶水适配器 + 微中心
一个组件的Interface定义了对外的能力接口。
我们又给组件加了一层接口:Adapter 。 它的作用是定义对外需要的能力。2个接口各司其职:一进一出。
这个adapter其实就是这个组件的适配器,是组件赖以生存的原料。
1、我们在组件构造器中加入一层胶水,来完成对组件的适配,有了这层适配器,组件的使用和生存环境变得非常灵活,我们可以在其中加入一些复用价值低的组装逻辑,这里也是一种动态代理模式,业务方也可以灵活将代理转向自己的业务环境来适配组件;
2、我们针对业务组件全部去除了直接的接口依赖,组件只定义自己关心需要的内容,而不是直接获取接口了。针对使用非常频繁通用的接口(如上报,日志等),我们保留了一份微中心,只包含少量的基础组件。
整体结构如下:
这里给出一个消息服务组件的适配器示例:
MessageServiceInterface messageServiceInterface = new MessageService();
messageServiceInterface.init(new MessageServiceAdapter() {//消息服务适配器
@Override
public ChannelInterface getChannel() {//通道能力
return serviceManager.getService(ChannelInterface.class);
}
@Override
public DataReportInterface getDataReport() {//数据上报接口能力
return serviceManager.getService(DataReportInterface.class);
}
@Override
public HttpInterface getHttp() {//htpp请求能力
return serviceManager.getService(HttpInterface.class);
}
@Override
public long getAccountUin() {//获取当前用户的账号UIN
return serviceManager.getService(LoginServiceInterface.class).getLoginInfo().uid;
}
@Override
public long getAnchorUin() {//获取当前主播的UIN
return serviceManager.getService(RoomServiceInterface.class).getLiveInfo().anchorInfo.uid;
}
});
这个模式不是最优秀的解耦方案,但是却是非常使用中台“台情”的~
优点:
1、不同业务组件完全解耦,包括对接口的依赖,组件可单独抽离使用
2、在复杂的业务定制场景下,组件可用性极大加强
五、框架中的痛点与解决
痛点1、膨胀化的直播房间页面,UI层难以管理,模块增减代价大
直播房间是个业务相当多的页面,并且会随着业务膨胀。
1、当我们将布局全部写在UI层时,会造成布局文件迅速膨胀,当要删减模块时,代价非常大;
2、管理混乱,不同的共建者往布局里添加布局后,会造成层级难以管理,UI性能差
页面模板化:
为了轻松管理房间布局及解决层级问题,我们将每个页面实行模板化。
第一步:层级规范
我们将每个页面都分为3层:顶层,业务层,底层
顶层:只放一些豪华礼物动效,状态stateUI
业务层:放各个业务模块的UI
底层:音视频渲染模块
第二步:按层级注册模块
业务模块索引自己对应层级,防止扩层UI滥用
第三步:布局下沉
每个页面无真实布局,只留有业务的坑位,通过业务模块组装将坑位指向需要它的UI组件,布局全部下沉到UI组件中
统一的模板组装框架
我们将页面划分成一个一个模板
1、每个模板有个模板配置,模板配置包含层次布局和层次模块注册;
2、一个注册模块的原子单位我们称之为Module,一个模板配置由不同的Module原子单位自由组装;
3、一个页面可以对应多个模板,框架会将对应层次的根布局给到注册到对应层级的模块原子单位
4、模块拿到对应ViewStub坑位后选择性交给一个UI组件去填装
5、UI组件拿到坑位后内部填充自己的业务布局
在Android里:
一个page对应一个Activity,一个模板对应一个Fragment或一个View,一个模板对应N个Module原子,我们通过jetpack的LifeCycle与模板原子生命周期绑定。当随模板启动后,Module有相应的生命周期,Module也会随着模板的销毁而自动销毁。 这样我们能轻松实现界面的动态变化。
例子:当我们切房的时候从一个直播房间切换到了一个小视屏房间,只要换个模板启动就OK了
相应代码示例:
//选择一个UI组件提供槽位置
getComponentFactory().getComponent(AnchorInfoComponent.class)
.setRootView(getStubRootView())
.build();
//UI组件内部索引自己的布局
@Override
public void onCreate(ViewStub rootView) {
super.onCreate(rootView);
rootView.setLayoutResource(R.layout.anchor_info_layout);
rootView.inflate();
}
有了模板配置,原子单位注册后,模块的UI才会绘制,对应的流程才会启动。当我们要去除一个模块或新增一个模块时,只需要剔除相应的原子单位或者新增一个原子单位就可以了。
模板化页面的技术收益
1、利用模板原子轻松组装不同的房间类型页面
2、层级维护可控,针对UI组件自动化检查和提单
由于具体的布局都下沉到了具体的UI组件,我们在构建时可针对UI组件做自动化层级检测,我们针对每个组件分配了默认的维护人,当一个UI组件>3层时提单给对应的负责人。
这样我们的层级会有一个比较好的保障:
模板化页面的业务收益
我们将模板分层中的业务层布局和业务模块注册开放出去,这样业务方可灵活定制组装自己的页面,做到更纯粹的模块管理和扩展性。
代码示例:
public class CustomAnchorRoomBizModules extends AnchorPortraitBootModules {
/**
* 中间业务层布局,用于各种业务,可以只定制这一层
* @return
*/
@Override
protected ViewGroup onCreateNormalLayout() {
return (ViewGroup) LayoutInflater.from(this.context).inflate(R.layout.custom_room_layout_audience, null);
}
/**
* 业务层模板添加相关的module
* @return
*/
@Override
protected void onCreateNormalBizModules() {
//这个为业务自己的module
addNormalLayoutBizModules(new CustomAnchorInfoModule());
addNormalLayoutBizModules(new CustomPrepareUpLeftModule());
//下面为sdk已有的module
addNormalLayoutBizModules(new RoomAudienceModule());
addNormalLayoutBizModules(new MiniCardModule());
}
}
定制示例:
痛点2:组装层职责大,灵活性太强,业务复用性差
我们前面讲到,将业务模块分成了2大组件UI组件和服务组件,由胶水逻辑串接了2个组件,这里单模块内其实是个MVP的架构,组装层相当于presenter
这里面临几个问题:
1、胶水和业务逻辑交杂在一起,module内逻辑有变动时,module层基本不可复用
2、业务逻辑和视图组件紧密耦合,基本不可复用,组件在开发中的定位模糊
2、组装层权限很大,灵活性太强,可持续性差
示例:当我们UI组件业务方有定制更改时,由于胶水层是与UI组件紧密联系的,这层组装层基本不可复用。
我们先来看下我们的诉求是什么?以及现状和问题是什么?
预期 |
现状 |
---|---|
1、module逻辑拼装层边界清楚,功能清晰明了 |
类似于MVP的Presenter,万金油能力,能拿到UI组件和服务组件,能写逻辑 |
2、业务逻辑在不同模块可复用 |
模块内部逻辑和模块的组装和组件有耦合,业务逻辑和胶水逻辑搅在一起,复用价值极低 |
3、业务逻辑接入方可灵活复用重写 |
得重写整个拼装模块和胶水逻辑 |
以主播信息模块为例:
组装层包含以下业务逻辑和拼装胶水,其中获取关注状态,关注请求和监听关注状态改变 这2个业务逻辑明显是可以被其他模块复用的,如结束页模块和资料卡模块
优化思考:
1、制定内部规范–明确指定module的职责
2、进行进一步拆解
单模块内架构优化
这里我们采用了MVP-Clean架构,细化职能,抽离胶水逻辑和可复用逻辑代码,将功能逻辑抽离成一个一个小的可复用片段
1、抽离后,弱化了拼装层职能,拼装只负责代理UI的Action,衔接数据回调刷新UI
2、功能逻辑层专职管理业务逻辑,并且内部的逻辑片段粒度非常小,可统一化输入输出接口
UI数据驱动:
UseCase执行后,统一生成一个基于LiveData的State模型,监听者为执行者module。UseCase会产生一个UI状态临时数据。
protected abstract void executeUseCase(Params params);
public void execute(LifecycleOwner lifecycleOwner, Params params, BaseObserver<T> observer) {
liveData = new MutableLiveData<T>();
liveData.observe(lifecycleOwner, observer);
executeUseCase(params);
}
为什么不直接让UI组件绑定数据?
还是前面提到的,我们UI组件的结构体是自己内部定义的,这样可以单独拿出去用,任何模块都不用依赖一个数据base。我们通过胶水层来代理转义,将数据层的结构体转换成UI层的结构体。
单模块整体模型,数据单向流:
我们在创建获取一个UseCase时,会将模板原子与UseCase通过LifeCycle绑定生命周期:
这样我们业务逻辑层可以不用关心生命周期,行为及ViewModel会跟随LifeCycle监听者一起结束。监听者就是模板原子,它是随着页面模板结束的,这样保证了我们的全局生命周期稳定性。
拆分后的技术收益
1、拆分后,中台模块的代码可复用和替换率极大提升
UI组件,服务组件,基础组件,逻辑片段,都可以我们可以复用和重写的模块。
拆分完成后,如果根据代码函数统计,可复用率可达75%
当然,这里只是一个预估值,这部分仍在持续拆分中~
2、在组件、逻辑拆分清晰后,可复用性高,单侧覆盖全,配对责任人,主流构建会定时跑单侧邮件输出单侧覆盖率,极大提升质量
痛点3:拆分后服务组件非常多,难以管理
面临问题:
1、逻辑层怎样获取数据服务?
2、数据服务非常零散,放到逻辑层创建管理非常混乱,该怎样统一管理?
3、数据服务需要有稳定的生命周期,例如房间销毁后,需要停止接收房间push,停止发送房间心跳等。
4、服务是有不同的生命周期维度的,某些服务又需要有同样的生命周期来维持整个系统稳定。
5、一组服务的生存环境是依赖一组关键流程启动的:如账号服务、通道服务等依赖登录,房间内的主播信息、成员列表、礼物等是依赖进房的
根据服务的生命周期和作用 ,我们其实是可以对这些服务划分作用范围的,根据直播特性,可分为以下几种:
Service管理设计思考
划分作用范围后,我们可以针对每个范围的服务加一层管理层,我们称之为引擎层,为什么是引擎?因为它既是一组服务的发动机,也是创建和管理服务的地方。
根据服务特性,添加三个引擎:LiveEngin、UserEngine、RoomEngine
LiveEngine:与整个直播场景生命周期一致
UserEngine:与用户账号生命周期一致
RoomEngine:与直播间生命周期一致
Engine包含2部分:
EngineLogic:负责引擎的环境启动,如用户引擎中负责登录创建通道,房间引擎负责房间的进房心跳环境
ServiceLoder:负责服务的生命周期和管理
有了Engine后,我们可以很轻松通过Engine获取创建服务了。但是对于不同的开发者来说仍然要去关注具体Service得由哪个Engine创建,一旦使用不当就会造成以下风险:
如下图2个例子:
1、在房间2个不同的模块中,我们使用了不同的Engine去获取消息服务,那么这时将会产生2个实例,这样消息数据很难同步,就会造成功能异常;
2、房间内的服务如果通过大于房间生命周期的Engine创建,那么在退房时由于我们只会销毁房间引擎下的服务,那么这个服务就泄漏了。
制约式双亲委派模型
为了彻底解决Service管理,我们借鉴类加载ClassLoader双亲委派机制 开发了一套适用于中台ServiceLoader的机制,我们称之为制约式双亲委派模型。
1、我们给每一个Engine加了一个作用域配置表,只有注册到作用域配置表的服务才有权限被Engine创建。有了这个配置表,所有的服务生效边界、生命周期都是可控的。
2、我们将LiveEngine设置为UserEngine的父Engine,将UserEngine设置为RoomEngine的父Engine。
3、框架根据业务场景只分配给各用户模块对对用Engine的ServiceLoader,Engine对业务不可见。
比如在房间,拿到的是RoomEngine的ServiceLoader,当去get一个Service时,ServiceLoader首先会判断Service是否在自己作用域,如果在,直接从自身去创建和获取已有的Service,如果不在自己作用域,再委托给父亲Engine,父亲Engine会做一样的事情,这里是个递归。 这也是制约式的由来。
这样做有什么好处呢?
1、避免了服务的重复加载,保障Service只能生存在自己应该存在的生命周期边界里,保证了程序的稳定性
2、服务生命周期的稳定性得到保障,不会出现滥用导致的泄露
3、开发者使用简单,屏蔽细节,无需关心service的创建者是谁,无需关心service的生命周期,这些全都由框架来保障
技术收益
1、服务管理清晰可维护性强,生命周期安全可靠。
当我们去切换一个房间时,只用destroy上一个RoomEngine,启动新房间的RoomEngine即可
当我们去切换一个账号时,只用destroy上一个UserEngine,启动新用户的UserEngine即可
框架可轻松支持房间多实例,用户多实例等复杂场景。
2、接入方可轻松使用服务层搭建自己的直播应用
对于想从服务层接入的业务方,可以轻松利用我们的这套引擎层来快速搭建开发自己的应用。
六、iLiveSDK的整体框架
iLiveSDK目前演变为以下架构
七、iLiveSDK的接入现状
目前直播中台已接入上线以下平台,为业务加入直播变现、直播带货、直播交友玩法等助力。
我们建设了比较全的iwiki文档,其中包含接入和共建的一些开发指引,也欢迎大家加入共建。
最后附上直播中台iWiki地址