作为 Tubi 毋庸置疑的核心产出,我们一直致力于提供令人赏心悦目的播放体验。Tubi 前端团队支撑着 Web 和十余个 OTT(over-the-top)平台,在维护和提升我们的播放体验上面临着诸多挑战。从起初仅支持单一平台到现在覆盖所有主流的流媒体平台,我们在这个过程中积累了丰富经验,并逐步孕育出一个激动人心的专注于播放和快速开发的项目:Tubi Web Player。这个独立项目可以帮助我们快速产出高质量的视频播放相关功能。本文将详细介绍该项目,希望你可以从中了解到一些有价值的信息。
===
🔎界定主要问题
The mere formulation of a problem is far more essential than its solution.
Albert Einstein
Tubi 在过去一段时间业务增长非常快速,这也带来一些新的挑战。因为我们所有的 OTT 平台共用一个代码仓库,当接入的平台不断增长时,如何解决不断膨胀的平台相关逻辑越发困难。一开始我们的改进方案在两三个平台上运转良好,但当扩展到十几个平台时就变得力不从心。我们意识到是时候重新审视播放器架构并开始设计下一代 Tubi Web Player 了,它要解决的核心问题包括三个方面:提升用户的播放体验,帮助提高代码质量,以及让开发者可以更好地共同维护。
在审视之前的播放器解决方案后,我们发现主要存在以下问题:
-
业务耦合
在项目早期,复杂度还比较小,将播放器和业务代码揉在一起并不会带来太多问题。随着时间的推移,持续增长且缺乏管理的业务逻辑,会让播放器模块的职能逐渐变得模糊。当扩展播放器模块以支持越来越多的平台时,我们不得不引入冗长的条件语句。对于一两个平台的底层播放器 SDK 来说足够的设计方案,在添加播放器功能时却成为明显的障碍。每个平台的不同关注点日渐增长,为维护带来了不小的挑战。
-
缺乏接口定义
播放器除了提供一系列方法以外,还会有许多事件在播放期间的特定时刻被触发,UI 组件可以订阅并响应这些事件。但是我们并没有提供清晰的文档给开发者,快速解答他们在开发过程中的问题,比如:什么是 ready 事件,它会在什么时候被触发;如果在一个新平台接入播放器,什么类型的数据和哪些接口的实现是必须的;在播放器内部,存在哪些状态......开发者在开发过程中不断产生类似的疑惑,但他们却无法很快找到答案。
-
代码质量较低
在过去,Tubi 的播放器方案是由临时的业务需求或者平台的技术要求驱动的,这导致它缺乏一个清晰的架构。此外,与代码量相较而言,测试的覆盖率和质量远远不足。大多数时候,我们只能依靠人肉测试来验证开发结果,这既不可扩展也不可靠。
🎯设定明确的目标
针对以上几个亟需解决的问题,我们设定了如下目标:
-
聚焦的独立模块
新的 Tubi Web Player 应该是一个独立模块,应用只能通过公开接口与 Player 模块进行交互,同时严格限制 Player 模块的内部实现,坚持“Do one thing and do it well”的原则,不允许夹杂特殊的业务逻辑。
-
简单易用、与平台无关的接口
因为我们的业务中大量应用播放功能,所以我们需要构建一个设计精良、丰富且易于使用的播放器接口。此外,我们需要支持十余个 Web 和 OTT 平台,因此播放器接口应该是与平台无关的,以便开发人员可以随意调用它并获得预期的行为。
-
层级清晰、设计优良的架构
播放器模块并不简单。我们需要在不同的 OTT 平台上支持不同的底层播放器 SDK。我们还需要支持各种字幕格式、广告播放和快进快退等操作。合理划分不同层级并确立它们之间的联系充满挑战,不过一旦我们能够建立一个完善的架构,添加功能和不断改进这一模块将会变得更加容易。
-
全面覆盖且可靠的测试
不只是单元测试,还需要更多的集成测试、E2E Testing 来保证质量,一些极端情况也需要能够在测试中覆盖。
-
详细清晰的文档记录
Tubi 前端团队已经扩充到十名成员,为了减少大家在重复问题上的疑惑,提高我们的效率和幸福感,一份详细而清晰的文档显得很有必要。为了达到这个目的,我们也需要找到一种好的方法来鼓励每个人轻松编写代码文档。
===
🗂选择对的方式去管理代码是好的开端
目前有两种方式创建 Player 模块。第一种是通过独立的仓库维护,将模块发布到私有包管理服务上,在项目中将其添加为依赖(我们目前在内部的 UI Kit 项目中就是使用这种方式)。这种方式的痛点在于管理依赖性。特别是在开发独立模块时,你需要在引用它的项目中用 Yarn 或者 NPM 与你的模块软链,但这个操作往往会引发一系列其它问题。
考虑到 Player 模块和我们的应用关系非常密切,经常有一些联动的修改,两者最好能够放在一起进行维护。所以我们选择了另一种方式:将我们的代码库改进为 Monorepo 并引入 Lerna 来管理各个模块。我们的项目结构演变为:
.
├── lerna.json
├── package.json
├── packages
│ └── player
└── src
🌸再通过 TypeScript 锦上添花
在 Tubi 前端团队,优先使用 TypeScript 是所有人的共识。通过 TypeScript 提供的静态类型检查、自动补全等功能,我们可以更加自信地提交修改,大大降低开发成本并提升代码的稳定性。在用 TypeScript 定义接口的同时,也意味着我们在解释接口的形状和用途,开发者在他们的 IDE 中可以看到模块的接口,接口方法上各种参数的类型,以及接口是否有返回数据等等有用的信息。与此同时,我们引进了 TypeDoc 这个基于 TypeScript 的文档生成工具,借助这个得力工具,我们仅仅需要在文件中合适的位置加上注释,就可以生成一份组织良好的文档。这些都非常有利于解决上面提到有关接口和文档的问题。
===
🍔接着,规划合适的架构
经过若干次迭代,Player 模块逐渐形成了全新的架构,自下而上依次划分为四个层次:Adapter、Player、Action 和 Reducer:
Adapter
不同的平台需要使用不同的底层播放器 SDK,比如 PS4/PS3 上的 WebMAF、三星电视上的 AVPlay、Web 上的 HLS、FireTV 上的 HTML5 Video Player 等。Adapter 层主要职能是处理具体平台内部细节,并向上层暴露统一接口。通过这种方式,不同平台的应用只需要依赖一个一致的接口,而开发者可以在接口的背后实现特定平台相关的技术要求。这正是我们为所有 OTT 平台提供一个代码库的秘诀。
我们利用 TypeScript 将所有 Adapters 约束到同一个接口。以下示例演示了 Adapter 接口,以及两个 Adapters 如何通过不同的底层播放器 SDK 实现它:
// types.ts
interface Adapter extends EventEmitter {
setup(): Promise<void>;
getState(): State;
getPosition(): number;
getDuration(): number;
play(): void;
pause(): void;
seek(offset: number): void;
remove(): void;
isAd(): boolean;
playAd(tag: string): void;
setQuality?(index: number): void;
...
}
// HlsAdapter.ts
class HlsAdapter extendsEventEmitter implements Adapter {
play() {
this.emit(PLAYER\_EVENTS.play);
this.videoElement.play();
}
...
}
// WebMAFAdapter.ts
class WebMAFPlayerAdapter extends EventEmitter implements Adapter {
play() {
this.emit(PLAYER\_EVENTS.play);
webMAF.nativeCommand('play');
}
...
}
除了 Adapter 接口要求的属性或方法外,Adapters 可以自由定义其它的属性或方法,从而能够满足特定平台的技术要求。所有的这些方法和属性都是私有的,感谢 Typescript。
Player
Adapters 会有一些共同的处理逻辑,比如初始化、设置默认字幕等。此外,我们希望每个平台以相同的方式使用 Player,所以对于上层来说,保持 Adapter 层和它们之间的交互尽可能简单非常重要。基于这两方面的考虑,我们在 Adapter 层之上增加了 Player 层,上层应该只知道并调用 Player 层。
在 Player 层中,我们定义了一组方法来控制内容播放,例如接受配置参数并初始化的 setup 方法,播放、暂停、快进等核心播放控制方法,以及更新字幕、码率等方法。每一个方法都会在内部同步或者异步地调用特定 Adapter 方法来捋顺复杂的平台相关逻辑。下面是一个简化版的 Player 层实现示例:
class Player {
play(): Promise<void> {
return this.adapter.play();
}
seek(position: number) {
this.adapter.seek(position);
}
...
}
Player 类同时也是一个可以发送事件的对象,应用可以订阅 Player 实例的事件来监听底层的状态变化,这意味着各式各样的平台相关播放器 SDK 都可以借助 Adapter 在 Player 类中实现相仿的事件和方法。
Action & Reducer
当需要处理复杂的播放逻辑,而且整个应用都重度依赖这些逻辑的时候,如果没有一个好的数据管理方案,开发者会非常头疼。Tubi 前端团队是 React 和 Redux 的忠实拥趸,我们决定借用 Redux 的能力来更好地管理播放逻辑和状态信息。如果对于 Redux 了解不多,你可以简单认为 action 就是调用 Player 的 API,而 reducer 就是 Player 暴露给应用的状态数据。Action 可以在应用的任何地方被调用,而 reducer 则会输出操作完成后的状态,这些状态可以被任意 UI 组件使用。
下面这段代码展示了我们如何在 Reducer 层存储几乎所有必要的数据:
// reducer.ts
const initialState: PlayerStoreState = {
playerState: State.idle,
contentType: PLAYER\_CONTENT\_TYPE.video,
seekRate: -1,
progress: {
position: 0,
duration: 0,
bufferPosition: 0,
isBuffering: false,
},
quality: {
qualityList: \[\],
qualityIndex: 0,
isHD: false,
},
...
};
我们的新实现中,Action 层会处理两方面逻辑:监听 Player 事件并更新 Reducer 状态,以及控制视频播放。下面的示例展示了我们如何监听 play 事件并发送相应的 action 来更新 Store 状态:
getPlayerInstance().on(PLAYER\_EVENTS.play, () => {
dispatch(transit(State.playing, {
contentType: PLAYER\_CONTENT\_TYPE.video,
}));
});
一些关联的更新也可以在 Action 层处理,例如当电影自动播放到下一个时,我们需要重置缓冲位置,流媒体清晰度变更后,则需要更新视频质量的 UI 按钮。通过引入 Redux 中的 action,这些复杂的逻辑处理可以集中在 Action 层中进行管理,而不是在Player、Adapter 等其它层次中,这正是一个层次清晰、职责明确的架构所期望的。
在 Action 层中另外一部分逻辑是播放控制 actions:
const controlActions = {
seek: (position: number) => (dispatch, getState) => createTimeBoundedPromise(
(resolve) => {
const { progress: { duration } } = getState().player;
const targetPosition = clamp(position, 0, duration || Number.POSITIVE\_INFINITY);
getPlayerInstance().once(PLAYER\_EVENTS.seeked, resolve);
getPlayerInstance().seek(targetPosition);
return {
// remove event listener if target event timeout
destroy: () => {
getPlayerInstance().removeListener(PLAYER\_EVENTS.seeked, resolve);
},
};
},
),
...
};
在上例中,seek 方法提供了一个更高层次的接口,封装了 Player 方法以及事件,返回了一个具有超时判断条件的 promise,它会在快进操作成功时 resolve。这种封装可以非常显著地提高应用调用接口的容易程度,下一节会重点介绍这方面内容。
===
📺最后,关键是在应用中的实践
对于应用来说,Player 模块提供了播放器状态的 Single Source of Truth,一组高层次封装的 actions,以及丰富的播放器事件。下面我们看一下如何将它和应用集成起来:
// WebPlayerOverlay.ts
class WebPlayerOverlay extends Component<Props, State> {
...
seek(position) {
const { dispatch, trackEvent, video: { id } } = this.props;
return dispatch(controlActions.seek(position))
.then(() => {
this.refreshActiveTimer();
if (trackEvent) {
trackEvent(eventTypes.SEEK, position, id);
}
});
}
render() {
...
return (
<div className={styles.webPlayerOverlay}>
<ProgressBar
bufferPosition={bufferPosition}
duration={duration}
isAd={isAd}
position={position}
seek={this.seek}
/>
<PlayerControls ... />
</div>
);
}
这段代码中,WebPlayerOverlay 组件从 Redux Store 中获取播放进度、影片时长、缓冲位置和内容类型等数据,并渲染了 ProgressBar、PlayerControls 等组件。无论何时用户切换播放位置,WebPlayerOverlay 都会调用 seek action 来轻松地操作底层 Player,并在操作完成后上报统计信息、刷新控制栏活跃状态等。在 Player 模块内部,前面的 seek 操作会更新 Player 的状态,而 Player 的状态更新可以通过监听 Redux Store 被组件捕获,最终组件会根据需要按照最新状态重新渲染。简直太棒了!
💪我们可以做得更好
目前,我们已经完成了计划中的大部分工作,结果正如我们期望的那样:视频播放相关功能的开发更加聚焦和容易,代码质量也有明显提高,这些意味着不断打磨 Tubi 的播放体验变得更加简便。将与平台密切相关的 Adapter 剥离出来意味着我们可以放心地对特定平台的播放参数进行微调,而不必担心这会引起其它平台上的回归问题。
展望未来,我们希望能够推出更多实用功能,例如支持 DASH 流媒体协议、构建跨平台的 DRM、实现自定义轻量级 MSE 封装等。与此同时,我们也会持续改进内部工具,包括引入状态机来更好地管理复杂的播放器状态迁移、增加各种媒体文件的 E2E 测试等。通过这一系列努力,我们期望能够持续地在更多平台上支持愉悦的播放体验。