云课堂视频播放器设计

背景

教育产品部的各个产品(云课堂C版、云课堂B版、中国大学mooc、网易100分)之间功能非常相似,为了避免重复开发,我们把一些通用功能封装起来,抽离到私有库中。过去的一年中已经有部分实践,比如网络库、直播间等。

视频播放器是各产品广泛使用的一个功能,但是,目前的设计通用性不太好。每次开发新功能,接入时都比较复杂,拓展和维护也比较痛苦。因此,决定重新设计和实现视频播放器功能。


需求分析

现有版本播放器主要有如下功能:

  • 播放器内核管理 现有播放器同时支持系统播放器内核和视频云播放器内核,在不同场景下使用的播放器内核会有所区别

  • 播放器控制 包括播放、暂停、快进、快退、跳转、倍速切换、清晰度切换、CDN切换、小屏/全屏切换、字幕控制

  • 手势识别 包括单击播放/暂停、右滑快进、左滑快退、左屏上下滑动调节音量、右屏上下滑动调节声音

新的播放器至少要实现现有播放器所有功能,另外,考虑到当前的背景,至少还需要满足以下几点需求:

  1. 简单易用:在云课堂最近几次迭代中,视频播放器被广泛应用到新的功能中,接入播放器应该尽量简单,这就要求播放器提供的接口尽量简单。同时,为了避免重复开发,新的播放器应该封装一部分业务相关但是各产品通用的功能,比如网络切换时的处理、App前后台切换的处理等;

  2. 差异化需求:由于需要支持多个产品,新的播放器应该能够满足各产品差异化的需求。视觉上,各产品视觉风格是不同的,颜色主题和图标不太可能会一样;功能上,各产品可能存在自己独有的需求,比如直播间的开发中,网易100分在直播间里引入了答题模块,而其他产品是用不上的,就算是同一产品,在不同使用环境,功能上也会有所差异,比如云课堂首页播放器没有控制界面。

  3. 方便拓展和维护:当前公用组件的开发模式是,一个产品先行开发,其他产品接入后根据自己需求继续拓展功能。因此,设计上需要一定程度保证尽量避免修复问题或者拓展功能的时候带来不可预期的影响,造成播放器在某些产品中无法正常使用。这就要求对播放器的功能进行细分,明确每个功能的职责,避免相互间产生不可预期的影响。另外,同样由于会用在各个产品中,后续功能的拓展应该是各个产品的开发负责,很难保证所有开发都会按照相同的设计思路编程,因此,新的设计应该提供一种框架,使得后续开发工作很自然的在框架下进行,同时要有足够的安全机制,并封装掉一些容易遗漏的细节。


播放器设计

功能分离

播放器功能较多,逻辑比较复杂。为了让播放器在未来具有足够的维护性,从层次上,将播放器的控制层分为两层:主控器层和子功能层。对于子功能层,考虑将支持的功能拆分成多个功能明确的子模块。如下图所示:

主控制器:负责管理子功能模块,同时对外提供接口

子功能模块:每个子功能模块都应该是功能明确的独立部分,目前有五个默认的子功能模块:播放内核管理、手势识别、控制界面、状态界面和字幕管理。后续添加的功能也应该加入某个子功能模块或者独立为一个新的子功能模块

功能拓展

在这一部分里,将介绍让播放器满足三个方面的需求:

  1. 选择性展示:播放器使用在不同的场景下,展示的功能有所不同,比如,云课堂首页的视频不需要控制界面,只有课程视频有可能会有字幕展示。因此,播放器支持的功能应该是可以配置的。

  2. 默认功能替换:考虑提供手势识别、播放器内核管理、控制界面、状态界面、字幕界面五个默认子功能模块,然而不同产品对某方面功能可能存在独有需求,比如控制界面不需要倍速调节,或者倍速调节的范围不一样。因此,播放器提供的默认子功能模块应该可以被替换。

  3. 添加自定义功能:当前抽离出来的五个功能基本满足各产品当前的需求,但是随着业务的拓展,难免会有些独特的需求出现,比如弹幕功能。新的播放器应该能很方便对功能进行拓展而不会对原有功能产生太大影响。

考虑将主控制器对子功能模块的管理实现为一种抽象的管理,即所有子功能实现统一的协议,主控制器操作的仅仅是实现协议的对象,而并不知道实现协议的对象是什么。如下图所示:

协议主要接口:

- (NSString *)identifier;
- (NSUInteger)zLevel;
- (void)setStore:(Store *)store;
- (void)setupWithContainerView:(UIView *)containerView;
- (void)clean;

其中setup函数在子功能模块加入主控制器时自动调用,用于初始化配置。clean函数在注销时自动调用,用于清理。setStore为配置模型数据,后续再做介绍。identifier用于唯一标识每个组件,如果identifier重复,在注册进主控制器时会报错,debug模式下直接闪退。zLevel函数定义了子功能模块在主控制器中的层次信息。

主控制器默认会创建手势识别、播放内核管理、控制界面、状态界面和字幕管理五个子功能模块,使用方可以根据需要注销某个子功能模块,也可以提供实现BaseComponent协议的子功能模块,交由主控制器管理。

对于默认子功能模块,我们可以轻松的指定各模块view的先后顺序,但是怎么保障用户添加的自定义子功能模块能够加到用户预期的位置呢?比如,将自定义功能模块的界面加到播放界面和控制界面之间。

最简单的方法是给每个模块指定一个位置信息,在播放器初始化的时候顺序添加子功能模块。然而,自定义功能模块的行为是难以预期的,使用方可能希望在合适的时机再把自己的界面添加上去,或者会在工作流程中多次移除、添加、替换界面。因此,考虑所有子功能提供一个自己的位置信息zLevel,同时,给UIView增加一个category,可以给UIView赋值zLevel,并提供一个函数xxx_addSubview,保证通过这个函数新添加的subview会根据zLevel插入到subviews的指定位置。

为了不增加使用的复杂性,考虑将这个过程进行封装,因此,播放器的库里提供ViewComponent的类,使用方继承这个类来实现自己的子功能模块,就不再需要过多考虑view前后的细节问题。

示意图如下:

ObjectComponent 对应不提供界面的业务子功能模块

ViewComponent 对应提供界面的子功能模块,封装了添加subview到指定位置的细节

事件传递机制

iOS中,view和view controller的层次体系是一个树形结构,如下图,考虑这样一种情况,如果我们要把一个事件从D传递到F,通常有两种做法:

  1. 直接传递,从D-B-A-C-F,这种做法的问题是路径太长,调试困难,而且各元素间耦合很强

  2. 使用中介进行传递,最典型的做法是使用Notification,但是Notification管理比较困难

我们希望能够对播放器复杂的业务进行分离,拆分成若干个功能明确且相互间关联很弱的功能模块,方便未来的维护和拓展,因此,使用中介来传递事件更符合我们的需求。在不考虑使用Notification的情况下,很自然的就想到了KVO。

播放器模块中Store就是这么一个中介者的角色。它由主控制器创建并持有,传递给所有子功能模块。store里保存了播放器的所有状态。每个子功能模块都监听store里自己感兴趣的属性,如果对应属性发生变化,子功能模块则做出对应的变化。同时子功能模块可以根据自己需要修改store里的属性来传递自己的事件。

这么设计的一个好处是,每个子功能模块的输入和输出都会比较明确。当然,我们更希望通过代码来让输入和输出更加明确。

明确的输入

子功能模块都继承自ObjectComponent或者ViewComponent,对于store属性的监听都被基类封装,子类需要重载下面两个函数,提供自己感兴趣的键值和响应对应键值发生改变的变化,包括store属性的变化,和事件的发生

- (NSArray<NSString *> *)interestKeys {
   NSAssert(false, @"should be overrided by subclass");
   return [NSArray array];
}

- (void)valueChangedWithManager:(KVOManager *)manager key:(NSString *)key {
   NSAssert(false, @"should be overrided by subclass");
}

明确的输出

子功能模块的输出分为两种,对store属性值的修改和触发事件,两者的区别是,store中的属性值表示播放器当前的一种状态,而事件则是一次性的。

对于store属性值的修改需要通过proxyStore来实现,其中封装了对属性值访问的权限控制,没有申明修改权限的修改操作都会导致debug模式下闪退。申明方式为重载下面的函数:


- (NSArray<NSString *> *)authKeys {
  NSAssert(false, @"should be overrided by subclass");
  return [NSArray array];
}

这样写,方便使用方简单的阅读createAuthKeys里面的代码,就能很方便的知道子功能模块会影响到哪些属性。

事件的触发是通过唯一入口函数实现,在文件中搜索对应函数,能很方便的找到所有可能出发的事件。

-(void)triggerEvent:(EventType)eventType withValue:(NSObject *)value;

播放器内事件传递的时序图如下:

由于播放器支持自定义子功能,自定义子功能可能会有这样一个需求,传递一些事件给外界处理,对于这种需求,使用方只需要自定义一个大于阈值的枚举值作为事件类型传递给store,外界会通过主控制器的delegate得到响应,流程如下:


自定义界面元素

由于不同的产品有不同的颜色主题方案,播放器中的颜色、字体很有可能不同。这部分差异是通过底层的皮肤库来满足的。在此不做详细介绍。

类图

两个前面没提到的部分:

  • BaseVideoPlayer

    播放器内核基类,主要作用为统一不同播放器内核的行为,并进行一些简单的控制。我们目前需要支持系统播放器内核AVPlayer和网易视频云提供的播放器内核NELivePlayerController,两个内核在一些播放行为上表现不太一致,因此需要进行一层封装,自己维护播放状态的管理。同时,如果未来有支持更多播放器内核的需求,我们也只需要改动这一层,保障不会对义务逻辑产生太大影响。PS:当前版本只实现了网易视频云的播放器内核。

  • VideoConfig

    config为外界创建并传入的实例,包含对播放器的一些配置信息,比如支持的清晰度类型等。config由store管理,各子组件通过store进行访问。


后续工作

  1. 模型层的拓展

    目前的播放器包含了我们所有需要支持的主流程,但是随着业务的发展,各个产品难免会添加自己的子功能,目前只有store用于存储播放器的状态,如果后续所有状态都往store里添加,store很快就会变得难以维护,因此需要一个设计方案来满足这方面的变化

  2. 性能优化

    播放器的性能主要受两方面影响。播放器内核和界面的层次。播放器内核目前主要使用视频云的播放器,有足够保障,当前界面层次体系对性能的影响,会在后续进行测试。

  3. 事件的分发

    目前事件的分发是由个子功能模块监听store中的eventType属性响应的。目前定义的事件有接近20个,但是每个子功能模块关心的事件都非常少。KVO的机制导致了每次事件,所有监听eventType的子模块功能都监听到,虽然大多只是空的调用,但是仍然是需要优化的地方。另外,通过KVO的方式实现事件响应,和一般store属性变化的响应没有明显区分,容易让人误解。考虑在后续开发专门的事件分发机制。


本文来自网易实践者社区,经作者彭衍授权发布。