幕后故事:交互视频播放器的技术发展历史

今天想简单讲点幕后故事,一个简单的交互视频背后那些复杂的技术迭代历程,以及现在它究竟以什么样的方式运作。这篇文章会分成两部分,史学的部分和如今的技术架构介绍。

这篇文章会着重笔墨在发现问题、和解决问题的过程,以及我们是如何通过架构设计的方式来重新组织业务需求,并作出合理抽象的,对我个人来讲这是一段生命历程的记录;对各位读者来讲,我也希望它能起到一些启发性的作用。

史学的部分

史学的部分我想延着时间线来讲一些故事。

混沌的摸索

整个项目启动于三年前,那个时候所有人对于「交互视频」这个东西究竟是什么东西还没有定论,所以整个项目都处于一种实验性的状态。当时我们「交互视频」这个东西有一个大概的共识:

  • 它是某种课程,展现形式是一段视频一段「交互」(比较接近小游戏)穿插着推进,和 YouTube 视频中间插的广告比较像,只是中间插的东西是一段程序而不是一个视频;
  • 「交互」是一个定长定宽,不能随画面缩放的模态框,出于「技术限制」没有办法做到和视频内容「无缝衔接」,也没有考虑移动端的体验问题,换言之我们假设所有用户都是在电脑上体验交互视频的;
  • 对于移动端,当时的想法是「移动端搞一个小程序就好啦,但是小程序不能放交互只能放视频」。

这个时候工程的样子差不多是这样的,所有的 Code 都在一个仓库里,包括交互课程,交互程序只是简单的从一个目录中 import 进来。

最开始的架构模型最开始的架构模型

整个团队延着这个方向走了很长时间。燃鹅第一次回旋镖打脸事件很快就出现了,在新的产品经理加入团队之后,提出了一个需求,希望交互和视频是无缝衔接的。这就意味着「交互」的部分不能用简单的模态框弹出来处理,必须得跟画面对齐。为了把某个东西直接缩放,最直觉的想法肯定是用 CSS Transform 直接把画面缩放到跟视频一样大,然后简单调整一下坐标就好啦!但事情并不是这么简单。

为了追求「极致的流畅体验」,整个交互视频是用 Canvas 直接画的,它渲染机制和一般的 DOM 元素的渲染并不一样。如果你用 CSS Transform 缩放了一个按钮,那这个按钮依然可以被正常点击,坐标也会被正常映射和计算;但是 Canvas 元素被 Transform 之后事件坐标和响应热区不会被映射,这意味着开发者可能要徒手根据 CSS 属性重新计算事件坐标。这个时候团队当中出现了两种技术方案:

  • 重新实现一套合成事件,爬取整个元素的 CSS 变换操作,计算最终的 Transform 结果,通过类似装饰器的方法来对事件属性进行修改,最后按照修改之后的事件属性来进行鼠标事件响应。
  • 把 Canvas 装进 iFrame 里,缩放 iFrame,这样浏览器就能对鼠标事件进行正确处理了。

这两个方案本身都有相当大的技术复杂度的,比如,如果你想要用前者:

  • CSS Transform 的数值要手动合成,如果几个容器元素分别有不同的 Transform,最后想要合成一个的话会变得非常难算;
  • 所有事件都要徒手注入一个装饰器,所有框架的所有事件都要处理一遍,这会增加海量的口水代码导致工程变得相当难维护。

如果想要使用 iFrame 的方案,也会带来一些比较烦人的问题:

  • 媒体播放,在有些浏览器里 iFrame 里面的网页会被视作一个全新的页面,这会导致自动播放权限不能继承,这样我们就很难做到让交互程序刚进去的时候就自动播放一段有声内容,来延续视频分镜产生一种「无缝」感;
  • 通信,iFrame 内部和外部的通信并不像 React 父子组件通信那么简单,所有的事件都要通过 postMessage 来传递,同时 iFrame 外部对内部的状态是完全没有感知的,换言之你并没有办法知道里面的程序有没有成功启动,或者宕掉了。

但是经过漫长的讨论和权衡后,我们还是选择了 iFrame 的方案,事实上它也的确带来了很多额外的好处,这一点我们可以在后面接着讲。

这个时候整个工程的架构差不多是这样的:

第一次的架构变化第一次的架构变化

鲁莽的冲撞

事实上这可能是我在团队当中做的数个得罪人的事情当中,最招人厌恶的一个。在项目初期,我们所使用的 Canvas 上的渲染引擎是「自研」的,团队当中的某个成员自己设计了一个绘制管理库,并且以半强制的方式把这套东西推了下去。但是这套东西有很多的问题:

  • 功能缺失,热区管理本身就是不完备的,很多绘制功能也不全,有缺失的功能就要联系维护者花时间把功能加上,这种任务阻塞是比较烦的;
  • TypeScript 支持,我必须要反思自己在团队当中推动 TypeScript 的时候是不是太过莽撞,在未与所有人达成共识的情况下就开始展开 TypeScript 的推行工作,但时至今日我依然坚持现代复杂的 Web 项目,没有 TypeScript 是一种非常严重的工程纰漏,除非有除了「我学不会」之外的明确原因,任何一个团队都不应该拒绝 TypeScript。但很可惜这个库并没有做类似的工作,维护这个库的同事对 TypeScript 也非常抵触;
  • 必要性,事实上已经有的,功能完备的轮子在市面上已经有非常多了,自己造一个除了满足「造轮子」的热情之外对整个团队推进业务时没有过多好处的,反倒会增加一系列的维护负担。

这个自研库的维护者曾经讲过这样的一句话:「我曾经试着用 Canvas 实现三维渲染(Context 2D API),但是做到立面剔除之后就发现性能不够就放弃了,所以三维这块我们用的是 Three.js」。从这句话当中我大概意识到了一件事情:他并没有足够的图形学专业素养和工程能力来维护这个东西,如果继续发展下去只会拖满整个团队的开发速度。所以我们约了一个会,所有的开发成员坐在一起讨论了维护这个库的必要性。

Fun fact: 这个引擎最开始甚至是用 setInterval 来控制画面重绘的,而非 requestAnimationFrame,当时文档当中赫然写着如果你把 interval 调的足够小,那刷新率就会非常高,体验也会更为丝滑,which is pretty weird。

支持方的想法是「我们要有自己的技术积累,不能什么东西都用现成的,这个东西如果我们自己做了还可以开源了让别人用,这样我们可以主导开发而不是被动的受人控制」。而我驳斥的理由也很简单,作为一个小型的初创团队搞这种事情不会有任何的短期收益,只会拖慢开发进度,这件事情是非常致命的。而且团队当中并没有第二个成员参与维护这个库,如此重要的基建完全压在一个人身上,对于商业公司来讲可以说是非常危险的一件事情。

最终整个库的开发计划被搁置,团队转向了 PIXI + Three + D3.js 的技术栈。

平凡的跃进

后来,机器学习那个项目就发布了。就,发布了。

到下一个项目发布开发启动的空档,我们有机会喘一口气来整理一下目前工程当中的问题。在我看来,当时团队当中最大的问题就是造轮子:事实上除了前文所述的渲染库之外,整个团队还有各式各样的轮子和独特的技术品味,几乎每一个成员都在用一套自己造的轮子来管理渲染过程,哪怕所有人都在用 PIXI,每个人还是各自封装了一套自己的画面绘制工具库。这是一个非常严重的问题:

  • 从商业角度讲,像我们这种小型的初创团队必须将自己的精力聚焦在真正的业务开发上,而不是每个人都搞一套小轮子来满足自己的成就感;
  • 从开发管理的角度讲,每个人都维护一套轮子必然会导致每一个轮子都有独特的 bug 要维护,每个人的维护工作又不能让其他成员受益,这种长此以往必然会损耗大量的精力;
  • 从工程健康的角度讲,它阻止了我们收束开发范式或者制定统一的开发规范,让每个人之间的交叉维护变得困难,也让新成员加入团队之后面对 N 种开发范式而显得相当茫然。

所以我们专门开了一个会进行商讨,把所有的轮子拉出来做竞标,目的是只留一个活的剩下的全部淘汰掉。我的立论主张也很简单:

  • 我并不反对进行业务封装,但是这种封装必须让代码更具表现力,隐藏掉无用的细节,突出主体业务逻辑。事实上因为赶工期的原因,机器学习那个项目当中充斥了大量的复制粘贴代码,当时团队里面还给这种行为冠以「抄作业」的名号,即一个人做了封装,或者处理了某个技术问题之后,所有人都去把那段代码拷贝到自己的工作里,这让工程的主体逻辑被淹没在了各种无用的实现细节当中,这给后续的工程维护带来了非常大的困难;
  • 为集中化管理留下充分的空间,换言之诸如帧管理(Ticker)、状态管理、生命周期、通信这些业务必须被集中管理和妥善封装,和具体业务逻辑划清明确的界限,这样可以在尽量不改动业务的情况下对整个项目做稳定性优化、性能调优和 bug 修复,避免出现一个修改改一百万处的情况;
  • 尊重 PIXI 和 Three 的 API 设计,不在渲染管理上做过多文章,也不做「统一 PIXI 和 Three 的 API」、「让 PIXI 和 Three 的 API 更符合自己的技术审美」这种吃力不讨好的事情,把精力真正的集中到业务上,尽可能把「交互视频」和「WebGL 渲染」两个东西连接起来。

我一直觉得,商业软件的业务开发,更重要的是研究怎么把工程合理切割,让每一层都能相对纯粹的表达「做什么」,尽量把「怎么做」隐藏好,一层一层的叠成一个作品,就是好的商业开发作品,也是老调重弹了。

后来我搞的那套架构变成了交互开发的标准工具库,同时我们也为交互的「沙盒模型」进行了调整。

iFrame 可以说是「微前端」的「鼻祖」了,它有一些非常优质的特性:

  • 比如说只要把 URL 设置成 about:blank,所有的内存泄漏、忘记停掉的 RAF 都会一扫而光;
  • 极好的安全性,考虑到整个项目未来可能会发展成平台,委托外部成员参与工程的开发,因此保护整个主站不被第三方代码篡改是很有必要的,通过 iFrame 进行沙盒化之后可以只暴露必要的 API 封装,阻止不受控制的代码在主站上运行;
  • 每一个交互课程系列可以独立控制依赖版本,只要它开发完毕并且稳定了我们就可以永久性的把所有依赖的版本固定住不再升级,以避免各种跳版本号造成的花式爆炸。

但是在之前的工程当中这些特性并没有被良好的利用过,iFrame 里面是一个 React,交互程序的切换是用 React Router 来做的,这就是 React 在整个交互程序当中的全部用途了,显然这是没有必要的,而且把交互程序做成 SAP 只会让内存泄露问题蔓延到下一个交互里,所以我们调整了工程的组织和打包方式:每一个「交互点」都是独立的 HTML,每个视频间的交互任务完成,整个 HTML 就会被杀死,连带着所有常驻于内存当中的资源也全部清除,这在很大程度上缓解了长久以来的内存使用问题。

这个时候架构被切掉了一层:

被切掉一层的模型被切掉一层的模型

事实上尽管做了各式各样的「微优化」,也不能拯救项目整体很糊的样子。直到整个项目生命周期末端,我们的技术面依旧非常惨,惨到需要加班去维护。因为难以承担如此多的「历史包袱」,我们只得招募新的团队成员,把交互工程的播放器彻底炸掉重新实现。

哦,对了,Steam 上的客户端也是那段时间我糊堆出来的产物,本来以为很简单的活,结果因为各种业务逻辑全都耦合在一起纠缠不清,造就了一副屎包屎的壮丽图景。

多了一层 Electron 之后整个东西就变成这个样子啦!

又多了两层的模型又多了两层的模型

现代化过程

在重新实现工程的时候,首先要做的当然是整理整个业务的所有需求,我们必须得把所有的功能分门别类的整理到不同的模块当中。在接下来的介绍当中,我会逐个列出当时产品上面的问题,并给出我们对这个问题的解答。

先来看一下现代化过后的播放器架构:

一个「现代架构」一个「现代架构」

每一个模块的设计都是为了解决具体的工程或体验问题。比如说,老板经常抱怨的,这东西为什么经常黑屏?为啥这么卡?这个黑屏和卡其实要拆成好几个部分来看,最主要的原因其实是生命周期、通信机制和资源管理机制实现的有问题。

生命周期

我们先来看生命周期上的问题:

  • 老版播放器的基础架构设计是有问题的,它最开始假设视频后面一定接交互,交互后面一定接视频,依照这个假设硬编码了很多交互和视频切换的逻辑,并且把交互和视频的生命周期拆成两部分独立实现;
  • 后面虽然通过修改支持了视频和交互程序的任意穿插,但是原本很多考虑不周的部分并没有被妥善处理和重新设计。

这一部分问题的改造方案是这样的:

  • 交互程序和视频变成了平级的概念,被分门别类的装在了一个叫做「Stage」的组件里,这个组件的运行机制是一套「插件系统」,客户端从远端下载回来一个配置文件,决定当前分集有哪些资源,资源的类型又是什么样的,根据不同类型的资源,我们会调用不同的「插件组件」来进行渲染,但是每个插件所使用的生命周期是一致的;
  • 每个插件会把自己注册到 Core Manager 上,由 Core Manager 执行统一的生命周期管理,哪个插件什么时候开始预载,什么时候应该销毁,什么时候应该从隐藏状态恢复到启动状态,整个状态机都由 Core Manager 来控制;
  • 这样的插件机制也给未来的产品功能扩充留下了非常多的空间,比如你可以在中间插一个任意网页,甚至可以插一篇文章。

另外交互的生命周期也被统一设计的 API 给藏起来了,个人一直非常反对在一个完全被托管的状态下由开发者自己手动控制生命周期这种底层的玩意。所以在一开始的时候我非常激进的直接在框架层面把手动报告生命周期这件事情给屏蔽了,理由也很简单:

  • 一来框架自己有一部分资源预热的工作,交互程序本身根本没有感知,你直接向播放器报告的「我准备好了」根本就是一个假的「准备好了」;
  • 另外一方面把这种非常底层的 API 暴露出来很有可能让整个初始化流程变得很不「标准化」,导致各种奇形怪状的初始化方式(特别是如果你团队里面有那种很愿意搞微创新的人),进而创造出各种各样的 UB,最后框架层面做统一调整的时候会把很多东西碰坏了(这件事情我们之前就已经体会过了,很痛);
  • 开发者自己写的预载任务跟框架的预载任务非常有可能堆在一起导致程序开始的几秒掉帧严重。

所以长久以来我们的那套交互程序的生命周期都是不完整的,它只考虑了框架层面的资源预热而没有考虑交互程序自己管理的资源预载(比如字体、贴图、音频),基本上就是如果做不好不如不做等更好的方案出来了之后再说。

后来这块的 API 被好好设计了一下:

  • 在程序初始化的时候交互程序可以自己提交 Callback 来把自己的任务塞到框架的初始化队列里,然后框架本身通过 Time Slicing 来管理每一个子任务,确保这些子任务不会卡渲染;
  • 只有一个开发规约是提交的初始化任务颗粒度必须足够细以确保不会出现 Time Slicing Queue 管理不了的情况;
  • 框架和程序自己的预载任务都完成之后我们会 Resolve 一个 Promise,同时通知播放器和交互程序所有的预载工作都做完了。

通过这种方法成功的把「手动报告声明周期」这种非常危险的操作给藏了起来,达到了隐藏实现细节的目的。

资源预载机制

预载机制的问题主要体现在这几个方面:

  • 所有的视频都会在播放器载入时一起预载,换言之有多少个视频片段就会有多少个 Video 标签被创建,这样网络和内存的使用效率都非常低。也正是因为这种预载机制,所有 AMD 显卡的设备都会在载入网站的时候触发显卡驱动崩溃,设备会瞬间黑屏然后 fallback 回核显,Chrome 则会直接把硬件加速关掉,导致所有 WebGL 的渲染功能全都炸掉;
  • 资源加载时机编排有问题,特别大的资源并不能在进入程序之前提前载好,导致一大片黑色背景晾在那里,很长时间之后贴图才会载出来,看起来就像程序挂掉了,但其实它活的好好的。

这一部分的优化相对来讲偏门一些:

  • 通过生命周期的改造,视频预载炸显卡驱动的问题已经不存在了;
  • 至于交互程序的资源预载,我们设计了一个资源管理工具来统一收纳所有的贴图、音频等素材,并且标记好哪些是优先需要加载的,甚至要缓存到本地硬盘上,根据设置不一样在交互程序的 iFrame 载入之前,甚至整个播放器刚刚加载完的时候,资源就已经开始缓存到 idb ,Cache API 或者浏览器缓存里了;
  • 因为播放器和交互程序不一定跑在同一个域里,根据浏览器实现缓存有一定概率是不共享的,就算浏览器缓存共享了,idb 和 Cache API 的缓存很难透进去,所以我们通过 Service Worker 把交互内部所有的资源预载请求全都拦截交由 iFrame 外部处理,如果是手机 App 的话则有一个更加可靠的 Resource Loader Native Backend 来进行缓存处理;
  • 为了确保程序的稳定性,无论是交互程序还是资源文件都设计有容灾机制,他会延着一个「偏爱列表」逐个尝试本地缓存和各个 CDN 的资源是否可用,如果不可用就向后 Fallback。这个机制虽然看上去有点鸡肋但是在 API 的一致性上起到了很大的帮助,而且有一两次某个 CDN 真的炸掉了,线上却没受影响,可以说多一步客户端容灾还是很有用的。

做这块当时也是想给网站加上「课程离线缓存」的功能,可惜因为时间很紧加上后面项目停掉了,所以这块只做了一半,没全做完。后来 YouTube 上出现了类似的功能,看到的时候我的心情还蛮失落的。

资源管理机制

这一部分机制的设计其实是为了统合一些杂七杂八的问题,比如:

  • 因为音轨和视频轨是分开的,所以在资源上传的时候,必须得分开传两个文件到 CDN 上,这意味着内容创作团队必须提前把视频和音轨分开,这种毫无意义的工作非常恼人;
  • 交互程序内存在着大量的资源 URL 硬编码,虽然后期出现了一个简单的「资源上传后台」,但是那个「后台」只能处理视频和音频的上传工作,图片之类的资源还是要自己到阿里云 OSS 面板上搞,最后再把网址粘贴进程序;
  • 我们系统的运行环境是相当复杂的,光是线上跑的就有测试平台、生产平台、教育版平台,后面还延伸出了 Steam 客户端和各种杂七杂八的运行环境,但是「资源上传后台」的架构设计完全没有考虑到这种复杂性,只是简单的在数据库里面存了一个 URL,这让多个平台之间的数据迁移成为了一个不可能的工作。在当时如果我们想要在新的平台发布节目,只能靠土法把 SQL 导出再导入到另外一个站,或者干脆重新上传一遍资源;
  • 另外,这个「资源上传后台」的操作也是相当的变态,一层又一层的模态框,弹出来各种各样的表单填信息,以至于后期整理上传资源的时候我都被搞出了 PTSD……
  • 在 Steam 客户端上就更离谱了,得靠正则解析和手写脚本把项目编译产物当中所有的 URL 都下载到本地,然后用 Electron 的 API 拦截网络请求让它从硬盘上读取数据,所以每次工程更新的时候我都得重新走一遍这个很莫名其妙的流程把在线项目「离线化」(因为 Steam 版本发布一直都是我在做,所以我真的知道这东西多痛);
  • 多端适配,当时的设计稿当中出现了很多「移动端用这张贴图」「桌面端用那张贴图」的情况,类似的需求当时都是靠每处分别手写逻辑来做的,这种代码多了会让工程显得很杂乱;
  • 多语言,URL 硬编码了,多端适配又搞出那么多贴图的 variation,再加上多语言的需求,维护成本马上就起来了,这点不言而喻。

为了解决这块杂乱的需求,我们把旧的资源管理系统整个砍掉重新做了一遍,大致的运转逻辑是这样的:

  • 无论是视频还是交互里面的媒体资源,概念上不再被分开对待,全部被称作「资源文件」,每一个资源文件有可以被计算权重的「标签」;
  • 「资源文件」可以被打成「资源组」,在一个分组内的资源可以通过选择器对资源文件进行选择,选择的依据就是资源标签的内容,这个时候事情就会变得明朗起来了:
    • 对于一般的视频,我只需要指定要哪个资源组下的文件,然后从组里面指定「资源类型是视频、音频还是字幕」、「语言」、「平台」这些信息就可以自动把对应的文件筛出来;
    • 燃鹅大多数信息并不需要你自己徒手添加,你只需要把有音轨的视频和字幕之类的文件框起来扔到资源管理器里,他就会通过预处理插件自动分离视频音轨(甚至可以做到自动识别 Safari 的编码问题进行修复,这个功能只做了一半还没实装),把数个视频打成一个资源组,自动根据资源类型进行标签标注,你要做的只是进去编辑器把额外的少数信息补上就行了;
    • 这些资源在进行「发布」前都是存储在本地的,只有按下了发布按钮之后才会分发到各个 CDN 上,分发的过程是自动的,不需要一个 CDN 一个 CDN 手动的传;
    • 对于交互内贴图道理也是类似的,把一组资源框选一起拖到资源管理器里面,它检测到是一组贴图就会自己打成一组,然后把各种标签填一填,开发的时候只需要把组编号写上就行了,不需要关心具体的语言、平台切换逻辑,这些实现细节都被「选择器」机制整合在了一起并且藏得很好;
    • 资源管理器有一个自己的小服务器,你在进行本地开发的时候可以直接载入本地的资源而不需要先传到 CDN 上,拿到 URL 再硬编码进工程。
  • 资源元信息的处理也被「拉平」成为了一个平级概念,对于离线客户端,资源管理器会直接生成数据包,对于在线客户端,资源管理器会把数据分别上传到各个平台上,这样就避免了各种平台横向迁移数据的麻烦;
  • 最后通过一点简单的操作,我们甚至实现了一键输出 Steam 客户端和 Android 客户端安装包的功能,整个打包发布过程的业务流程彻底被梳理干净了。

通信机制

交互程序和主站的通信机制一直都是有问题的,如果你打开旧项目,同时开两个 Tab 就会发现它们的通信会乱掉。造成这个问题的原因很简单,整个通信流程的运行方式类似于向 0.0.0.0UDP 广播,信息发到哪里,不知道,发没发到,也不知道。所有通信都跑在裸的 Post Message API 上,所以很多时候主站往交互程序发消息,但是交互程序根本没起来,信息没有送达导致整个加载进程就那么死在那里了。

这也为后续的需求实现留下了很大的隐患,之前有同事提出想法,想要在电子杂志当中穿插交互小程序,来做成交互式的杂志,但是以目前的通信方式来看,我们只能在每个页面当中插入一个交互程序,这很明显是不可接受的。

Post Message 另外一个很大的问题是它类型不安全,不管你怎么封装都很难做到优雅的类型安全策略,加之为了缓解「低端设备」性能不良的问题,我们和云游戏厂商合作需要做云端网页集成,这时候内外通信从 Post Message 变成了 Web Socket,通信层面的东西如果没有做解耦的话整个工程会变得非常混乱。

为了解决通信层面上的可靠性问题,我们引入了 Jack WorksJSON RPC Call,把通信的方式和通信的内容拆成两层,这样中间跑的是 Post Message 也好,Web Socket 也好,甚至是 HTTP 协议都不会有太大的问题。

自动播放

Oh my gosh… 这东西绝对是最折磨人的,因为 W3 没有给媒体播放行为设置特别有约束性的标准,所以各家浏览器引擎都是发挥足了各式各样的创意来阻止你的浏览器发出声音,Chrome 还好,你只要跟页面交互了就能发出声音了,Safari 则是要求所有有声音的视频都不能自动播放。这意味着被分开片的视频完全没办法一个接着一个的放下去。

在旧版播放器当中,为了判断当前环境能否触发自动播放,代码当中硬编码了一个无声 URL,播放器会尝试播放一下这个音频来确定 MEI,不过我很讨厌这种硬编码的做法。比如某天硬编码的 URL 炸掉了,或者客户端网络有波动,自动播放检测就没法工作了,这是很不可靠的。

交互程序内部也是重灾区,因为 iFrame 内部被视作一个独立的页面,所以自动播放权限是不继承的,你必须得至少跟 iFrame 交互一次才能触发音频自动播放,这给内容设计上带来了一定的阻碍,比如一种常见的创造「无缝切换感」的方法:

  • 视频部分播放一个分镜,在分镜的最后一帧进交互;
  • 交互内部放一个跟最后一帧一模一样的场景,继续播音频,并且展示一些简单的动画;
  • 突然告诉你,你可以跟画面交互了哦;
  • 很傻很天真的用户就会喊出「卧槽」。

嗯,这个真的发生过,而且不止一次,我老板都被骗过。

有很多好的创意都被浏览器阻止自动播放的激进策略给限制了,为了解决这个问题我们在全局放了一个单例的 Audio Station,和一个叫做 Evil Trigger 的东西。简单来讲就是 Evil Trigger 会尝试跟页面上所有的按钮和锚点绑定事件,只要按钮被按下去之后,全局单例的 Audio Station 管理的 Audio Context 就会被启动,后面所有的媒体播放请求都会从 Audio Station 走,这样就能做到花式自动播放而不用看浏览器脸色了。

啊,不过虽然理想是这样的,事实上 Evil Trigger 并没有做完,自动播放这件事情现在是和播放器的播放按钮绑在一起的,Which is good enough.

「自动播放」这件事情带来的成本其实挺高的,Audio Context API 的性质决定了所有音频文件必须得被全部以 PCM 的格式存到内存里,这非常吃内存以至于我们不得不把大多数的音频打成单声道并且降低采样率以防止移动端浏览器内存爆掉。

时间管理大师

另外一个很琐碎的需求是时间管理,很多边边角角的需求都需要这么一个东西:

  • 比如,Audio Context 和静音视频的时间同步(如果两边进度差太多的话就自动对齐进度);
  • 比如,视频字幕的加载;
  • 比如,老板想要的,视频播放到一半他的大头贴会从视频边上冒出来并且说一句话来指导用户;
  • 比如,那个发布会上讲的巨复杂的 BGM 系统。

这些东西在以前的播放器当中都是分开实现散落在各处的,但实际上我们可以把它统合成为一个单一的模块:时间管理。

音轨视轨同步看起来就直白一些,我们很厉害的同事实现了一个类似 NTP 的东西来校正视频和音频的时间,还有交互内外的时间,但是这个校正的过程涉及到一些心理学知识(Which is 我的老本行),要排出一些优先级顺序来,内部文档有写不过因为蛮琐碎的在这边就不多讲了。

其余的东西都可以理解为,在一条时间轴上会连续发生的事件,无论是 BGM 的切换、字幕的显示和隐藏还是对话框的弹出,所以我们给每个视频都附上了一个配置清单,来描述哪个关键帧要做什么,这种帧分两种:

  • 某个时刻:当时间进度跨越了这个时刻,就执行某个事件,可以分成只执行一次,和每次跨过都执行,对话框事件就可以用这个方式来做;

  • 某个时间段:当时间进度在这个时间段里的时候,就开启某个状态,否则关闭某个状态,BGM和字幕用的是这种,实际上类似 YouTube 的视频章节功能也可以用这种机制来实现。

具体触发什么事件用的是一种插件机制,要触发什么事件播放器会根据任务类型查该调用哪个插件,然后再执行对应的任务。

字幕做了一点额外的处理,SRT 格式的字幕导进去之后会被转换成时间轴上的关键帧,这样做可以方便视频创作的同事上字幕。依旧的,因为时间上的原因这块只做了一半,不是所有的时间轴任务插件都有对应的实现,要看后面会不会遇到对应的需求了。

一点悲惨的事实,最早期的播放器 BGM 实现是这样的:直接把要播放的所有 BGM 都硬编码进播放器里面,包括音频的 URL,无论你在看的是哪个项目,所有项目的所有 BGM 配置都会被载到内存里。

那个 BGM 的 URL 还不是完整的地址,而是一个需要过某个函数拼一下才能吐出结果的字符串,所以项目迁移的时候我们得手动一个一个地把这些地址挑出来然后处理一遍,就很痛。

当时推荐先「暂时」把地址写进播放器里的人还是我,因为那时候预告片已经发出去了,死线就在那里很多东西根本没时间打磨只能先堆上去再说,本来想的是「后面再把它剥出去」,可惜后来排期的人根本不 Care 这事情导致这种狗屎一样的东西就一直遗留在了工程里。

多客户端与皮肤系统

这东西把我同事坑的很惨,对不起!(ORZ——)

在项目后期遇到了这么几个需求,首先,要有 Steam 客户端,这个 Steam 客户端和主站得是完全断开的,这意味着所有的内容元信息必须离线。另外老板想让 Steam 版本的视频播放器和网站上的不一样,究竟有多不一样呢?那可是相当的不一样,可以说亲娘看了都认不出来的那种,绝对不是你 CSS 糊上去就能了事的。我们不可能直接 Fork 一个播放器改改就了事,随着项目越来越多,会有越来越多的播放器出现,维护成本会以指数级爆炸成长。

在此基础上,网站上也有不同的模式,比如「小交互」和「交互视频」就是两个截然不同的东西,如前文所述,我们还考虑过在电子杂志当中插入交互内容,它对应的皮肤可能又是另一套东西。为了处理这个问题,我们重新整理了一下架构,把数据抓取这一层单独拆了出来,做成了 SDK,不同的 SDK 处理不同类型的数据源,SDK 同时也掌管了皮肤热加载的过程。

皮肤热加载可以说是非常精髓的一块东西了,我们利用伟大的 Remote Component 实现了另外一种「微前端」:远端组件加载。整体的实现思路是这样的:

  • 这个组件返回一个 Hook 和一个 Component,这个 Hook 告诉我要向播放器注入哪些属性,Component 则负责包裹整个播放器,借以处理一些需要跨分集的内容;
  • 播放器本身的所有元素都是通过插件化实现的,Stage 层,Loading 动画层,字幕层,对话区层,每一层都是一个 API 完全一致的组件,通过和 Core Manager 通信来实现各自的功能,开发者可以通过 Hook 直接修改层顺序甚至换掉某些层、添加某些层,这给内容开发带来了相当大的自由度。

不过好看的花都是带刺哒,因为组件热加载是用 eval 实现的,所以如果你不在热加载模块里面打个 Console 的话甚至都进不去模块的虚拟机,更别提打断点做调试了,总之整体上是一个比较难搞的东西。再加上整个系统实在太复杂了,任何一个包的修改都有可能牵一发而动全身,播放器的行为总是可能会出现预期之外的变化,如果对播放器代码库不是很熟悉的话很有可能会被预期之外的 Breaking Change 扫到,这也是为什么我们至今都没有把各种库标到 1.0.0 的原因。不过这总归是一个要解决的问题,我们还是要花力气来做的。

性能与发热问题

最开始的产品设计其实是「不用做移动端」,但是直到内测前后又突然说「我们要考虑移动端用户体验哒——」,这需求变得让我们非常措手不及,很多优化做的都是陆陆续续打补丁打上去的,老板们,我爱你们哟 ♥~

说回正题,这块的优化和 PIXI / Three 的绘制机制有关,也跟各个浏览器厂商的 WebGL 实现有关。因为后来 Three 用的少了,而且主要是我同事在调优这块,所以暂且略过不讲,主要讲一下我在搞的 PIXI 这边。

Web 上的渲染引擎都没有做 Partial Render 这种高级玩意儿,每帧都是完整重新绘制的,所以它的性能和功耗都多多少少有点问题。加之 Safari 用的 WebGL 之前都是他们自己搞的,不仅不支持 WebGL2 而且性能烂的一比,所以性能优化是很急迫的事情。

新版的 Safari 总算扔了自己的实现开始改用 Google 的 ANGLE。自此,无论是 Firefox 还是 Chromium,Safari,用的都是同一套 WebGL to Native Binding 啦,不仅性能有很大的提升,bug 图谱也会变得没那么复杂。可喜可贺,可口可乐。

不过记得把抗锯齿关了,iOS 15 带的 ANGLE 有 bug,导致 Draw Call 顺序会出问题,Render Buffer 也清不掉,画面会出 bug。

最简单的办法肯定是做动态帧率,得益于之前提到的统一封装,我只需要偷偷在封装层后面把 Ticker 的行为变一变就好了,开发业务逻辑的同事没有受到任何影响,可以说是非常方便了。

动态帧率这块的做法是这样的:

  • 首先把基础帧率降下去,对于高分屏限制最高帧率为 60fps,为主线程抢出做事情的时间,如果用户没有任何操作的话画面帧率降到 15fps;
  • 实现一个帧率申请机制,如果有需要可以向 Ticker 申请高帧率,比如在播放动画的时候或者有特殊需求的时候;
  • 在统一封装的动画接口当中(一个 anime.js 的 简易 binding)申请 60fps 高帧率,确保动画不会卡;
  • 当用户的鼠标或手指与屏幕交互时,给 60fps 的满帧率,跟苹果的动态帧率机制有点像,但是我们没给到 120fps,毕竟是 Web 技术栈,不能奢求太多。

这套机制加上去之后移动端发热问题立刻就有缓解,我同事之前跟我吐槽过「原神都没你热」,现在不热了,可喜可贺。

另外一个性能问题是和任务调度有关,PIXI 内部实际上有两个 Ticker,渲染用的 APP Ticker 和全局管理鼠标事件的 System Ticker。 System Ticker 在文档里面写的不是很清楚,我是靠火焰图和源码才挖出来的。现在我们遇到的问题是,System Ticker 的性能非常不好,如果你画面当中可交互的东西非常多的话,System Ticker 计算 Bounding Box 就会把画面搞的很卡。为了处理这个问题我们把任务做了重排。第一帧用来渲染,被抽掉的另外一帧给 System Ticker 用,这样计算压力就彻底分散开了。

任务分片

这是另外一个比较玄学的事情了,我们都知道 JavaScript 是一个单线程语言,这意味着不用 Web Worker 的话大多数任务都要堆到一个线程上面来做,如果一帧做不完,你的页面就会开始掉帧。

这件事情处理起来比较 Tricky,我们参照 React Time Slicing 的思路,把大任务拆成小任务放进队列里面来做,所有任务都要排队,做完一组之后检查时间,如果时间还够就接着做,不够就跳过等下一帧在做。说起来很简单但是实际做起来没那么容易,中间涉及到各式各样的 Polyfill ,还调整了一下标准的 Promise API 加了两个功能进去。总之这方面的问题被解决了,最后的结果是好的。

以及不要跟我讲 Web Worker 这种事情,不是没搞过,在做机器学习的时候我甚至做过基于 Web Worker 的消息队列,是能用,但是开发体验一言难尽。

如果你的渲染卡了,用户会骂你网站写的烂,但是如果你画面没卡任务跑的慢,用户可能就会骂浏览器垃圾了。

一共有两处用到了这个优化逻辑,一个是播放器资源预载的时候需要排一下任务队列,其次是交互程序进行预载的时候需要排队,防止iFrame 里面的任务太多把外面卡死了。这个地方浏览器的模型比较复杂,一方面是 iFrame 跨域和不跨域的时候,安全模型是不一样的,导致行为也会有点区别;另外,一些浏览器的一些版本在 iFrame 没有显示的时候不会跑 RAF 导致交互内部的任务调度跑不起来,得靠播放器的队列系统来拉任务。都是比较好搞的事情,三五下就写出来了,一遍就成半个 bug 都没有。

多项目维护

后期的话有好几个项目在平行推进,但是很多基础配置的同步需要靠手动复制粘贴维护,为了处理这个问题我们把所有的打包器配置封成了独立的包,这个包也集成了一部分开发环境和 Service Worker 配置,这样开发者只需要升级一下打包器整合包,所有的打包配置都会更新,开发环境也会升级到最新的形态,开发过程中杂七杂八的事情会少很多。

Q&A

JUST, why not Unity?

这个问题其实非常微妙,有历史原因也有现实原因,在刚加入团队的时候我是喊 Unity 喊 Cocos 喊得最凶的人,但是后来在做技术决策的时候我也是率先提出反对意见的人,大致的想法有几个:

当时我所知道的,整个项目的定位是做一个平台,这个平台要有一个统一的入口,在这个入口之内进入各个课程。在这个需求下上 Unity 根本是不可能的事情。

  • 如果要做平台,那意味着必然要搞热加载:

    • 你不可能在苹果眼皮子底下搞这种热加载,一般项目可以偷偷的绕过苹果的限制搞些事情,但是这种热加载 as a feature 的事情苹果绝对是不会容忍的;
    • 主程序的版本管理跟交互视频程序的版本管理会变得很繁琐,主程序的 API 变动和交互视频程序的版本会形成互相 Blocking 的依赖;他能不像网站部署一样,随便上传一下所有人就都能拿到最新版本的播放器和交互程序;
  • 还有一些历史原因:

    • 你不大可能把现在已经上线的主站彻底关了,但众所周知的 Unity 在 Web 上的加载性能非常拉跨,那一大堆小毛豆一样的交互程序成堆的加载会造成一场难以处理的性能灾难(虽然后面我们也在播放器的 API 设计上给 Unity 的植入溜了空间,但具体怎么落实到业务上依然有很大的不确定性);
    • 如何处理播放器和 Unity 之间的关系,是把整个播放器砍掉用 Unity 重写还是搞混合模式推进,一半 Web 一半 Unity,如果搞混合模式的话移动端 APP 这边就变成了 WebView 嵌入一个播放器再跑 Unity on WebGL on Mobile Browser,这根本不是一个可以用的方案;
    • 如果整个播放器都重新用 Unity 重新做的话,要怎么做,怎么工程化,怎么重新处理平台化造成的工程复杂度;
  • 以及一些比较现实的问题:

    • 如果彻底放弃了 Mobile Web,那也就放弃了微信作为流量入口的可能性;
    • 钱方面的问题(Unity 的人真的很贵)以及现在团队已经招到的这些人怎么办,不是每一个人都愿意学 Unity,也不是每个人都学得会 C#。

整体上来看,引入 Unity 对于团队来讲会引入非常大的不确定性,这种不确定性需要很有经验的开发者来处理,也需要投入额外的研发人力和研发资金,但很不巧的是,真正到那个时间节点的时候,人力和资金已经全都木有了,所以也就只能硬着头皮往下搞了。

所幸通过一系列的调优我们(在最近)成功做到了用 Web 技术来达到接近 Native 的使用体验,这些研究经验其实非常有价值的。我们得面对一个现实:无论你多讨厌 Electron 多讨厌什么都套 WebView,多讨厌小程序,这些东西都在不可避免的入侵你的生活。原因也很简单,对于厂商来讲,Web 绝对是一个最具有性价比的方案,它可以满足产品快速开发、高速迭代的需求,也是开发成本最低的一种技术选型。所以我们会发现有越来越多的软件开始加入 Web 化的大军,它们开始混合 Web 技术栈甚至完全用 Web 技术栈重写。这件事情短时间内不会有任何回头的趋势,只会愈演愈烈。

我个人也讨厌什么东西都用 WebView,什么东西都 Electron 的浪潮,但真正落实到现金上的时候,商人们都是诚实的,That’s life。

结语

这么庞大的一个玩意想也不是我一个人能搞得定的,事实上在整个项目中我也就是定了个架构,搓了一部分需要各方协调的业务逻辑,顺带的做了一点界面和产品的设计。其余相当多的开发工作还是由我们超级靠谱的同事搞定的,只能说在经历了一波三折之后稳定下来的团队,每个同事都是个顶个的牛逼人士,没有他们整个项目不可能正常运转起来。

我还依稀记得最大的一次架构变动之后产品首次推到线上的那个晚上,本来以为会一如既往的 Bug 成堆,甚至出现全新的 Bug 图谱,但很意外的,播 放 器 它 没 有 bug,至少所有影响主线流程的 bug 全都被清掉了甚至没冒出来新的。这在我两年以来的工作经验当中是从未有过的,着实感动了一把。

整体来看,你可能会发现,每一个业务需求背后涉及到的都是很复杂的技术决策,如果产品前期的技术积累不够、架构设计不具包容性,那后期开发就只能一层一层的叠屎,最后屎山快速崩塌。经历了这几年的工作,我最大的感悟有两点:

首先是技术,技术真正的价值并不体现在面子上能拿出来的 feature,而是背后思考问题的逻辑和解决问题的方法。代码只是解决问题的手段,如果没有好的思路支撑,它也可能成为创造问题的根源;

其次是产品,在实现产品的时候,各个角色之间的角色和工作应当是清晰明确的:

  • 老板负责明确产品究竟是什么,它的内含概念和外延概念在哪里;
  • 产品经理要真正的围绕着产品的核心概念展开工作,换言之也就是去「实现产品核心概念」;
  • 设计师的主要工作是「实现产品方案」;
  • 工程师要做的是「实现设计需求」。

每个层级都各司其职避免发散整个产品才能快速的往前走,我举几个发散的例子:

  • 老板可能会不知道自己究竟在搞什么,动摸摸西蹭蹭,核心的东西一直定不下来的话,底层技术架构就会每日发生大地震,整个产品就会变得很不牢靠;
  • 产品经理如果不去为产品核心概念服务,沉沦于自己的幻想世界,每天都搞一些无用的外围功能,那产品的形态就不会稳定和清晰;
  • 设计师如果不能明白整个产品的行动目标,只是一味追求产品来满足自己的「审美品位」和求异的心态,那开发人员就会浪费大量的时间在无用的口水逻辑上;
  • 而开发如果不能意识到设计稿和产品需求的设计意图,不能直接的看到问题甚至为了「造轮情怀」止步不前,那团队的前进效率就会打折扣。

每一个工作流程都是为上一个工作流程提供「实现」,在商业项目的推进过程中把产品核心目标摆在第一位,而不是把自己的情怀、情感、情结摆在第一位是很严肃且重要的。毕竟你看,纵观中国互联网创业史,拿情怀当饭吃的大多都没有什么好下场。

以上就是这三年以来的技术团队的幕后故事,希望能给你带来一些启发,或是惊喜。

莉莉爱你 ♥。

Comments

No comments here,

Why not write something?