9
头图

前言

想必很多“投身于教育行业”的前端工程师们都绕不过“课件”这个话题,对于前端来说,课件项目是教育公司相比互联网公司特有的需求之一,对于公司来说也是及其重要的。目前教育行业我了解到的生产 h5 课件的方式大致分为以下三种,每种方式也是各有优劣,下面是我的理解:

  1. ppt 制作,通过三方或者自研平台转换成 h5
    这种方式可能更适合初创团队或者是开发资源较少的团队,而且一般配合着其他教学服务一起使用(比如直播),这样几乎可以完全不需要技术人员的支持,当然劣势也是明显的,首先这一般要依托于三方平台,其次是编辑端一般需要在 ppt 上完成,文件易丢失和泄漏。
  2. 研发手写课件,比如用 cocos creator 根据教研老师提供的 ppt 生产课件
    这种”流水线“是比较主流的方式之一,往往通过研发手动编写出的课件更加灵活,效果更好更生动,能够完成更复杂的课件。缺点想必很多伙伴也是深有体会,这种方式人力成本巨大,要有一支固定的游戏开发团队,需要教研、设计、研发、测试共同协作才能完成一讲课件。
  3. 提供编辑平台,教研在此平台直接制作并输出 h5 课件
    第三种方式也是比较主流的方式之一,据我所知目前也有很多团队在从第二种方式转到这种方式。这种方式一般是由开发提供课件平台,教研老师自己在平台上制作课件,这样一定程度的释放了开发人员,降低了协作成本,开发课件效率更高,周期更短。当然它也是有劣势的,一般这种编辑平台的开发难度比较高,其次就是前期教研老师对于这个平台的学习成本也不小。

有时候对于团队来说,三种方式不是互斥的,大部分情况三种方式是并行的,会根据内容的类型、复杂度等方面去折中选择。对于我们团队来说,其实也是三种共存的,不过大部分内容生产使用第三种方式,下面我来给大家介绍一下励步课件的技术体系。

业务场景分析及技术难点

介绍技术之前,首先我来简单介绍下我们课件使用的业务场景:

  1. 我们的课件分为两种,线上课件和线下课件,两种课件在内容相互衔接,风格没有明显差异,有区别的是线上课件需要做实时的师生互动,分为师生两端,教师用 PC 端,学生 ipad 居多,线下课主要是线下校区使用利用白板播放,线下除了课件播放,还需要一些辅助教学的功能。
  2. 支持了英语、数学、语文三个学科
  3. 生产线上课件与线下课件是同一个部门的教研老师。

其次我们再来结合业务看下我们要面临的技术难点:

  1. 性能要求高:对于上课的产品,要求是完全接近离线的体验,举例来说,图片、音视频等资源不允许出现缓冲等待;
  2. 容错要求高:灾备方案要考虑,比如断网,课件也需要能够正常播放;
  3. 风险高:对于上课场景来说,基本上一两分钟的系统不可用都是不能忍受的;
  4. 交互复杂:最后一点主要针对编辑端,做过编辑器的伙伴大致都了解,能够实现 ppt 那种功能交互是异常复杂的。

那么结合以上的业务场景和遇到的困难,我来给大家一一介绍我们的处理方式,在此之前为了让大家更好的理解,我先放几张我们整体的功能模块图以及功能演示图。

功能模块图:

课件编辑器:

课件播放器:

编辑器

目前我们的编辑器大致可以分为以下几大模块的功能:

  • 元件系统:这也是我们编辑器的核心功能,支持添加文字、图形、图片、音频、视频以及 iframe,每种元件都支持若干种属性的更改。
  • 互动系统:这其中还分为事件、动画、题型三个模块。

    • 事件模块:我们可以给元件做事件绑定操作,比如我可以给某个元件设置点击事件,点击后隐藏某个另外的元件,或者播放动画、播放音视频,再或者跳转到某一页等等。
    • 动画模块:我们支持自定义动画功能,当前支持折线动画,可以设置播放时长、循环播放等属性
    • 题型模块:课件中可以设置拖拽,选择,连线等题型
  • 通用模块:包括了基础的功能,比如复制粘贴元件,组合,撤销,图片裁剪,帧动画制作,资源库,页码操作等等

那么在实现以上功能的时候,有几个关键的技术点与大家分享。

Canvas vs Dom

相信如果你也做过类似的产品,在初期做技术调研的时候一定也会在 CanvasDom 之间纠结,实际上用两种方式都可以实现这类功能,市面上也都有成功的案例,但我们在开始之前还要针对我们的业务场景来综合评估,首先我们来梳理一下这两种方式的优缺点:
首先对于 Canvas 来说,

  • 优点

    1. 元素多的情况下,性能表现更好
    2. 不需要过多考虑重绘的问题
    3. 对于图片处理更加方便
    4. 三方资源较多
  • 缺点

    1. 上手门槛较高
    2. 元素少的时候会产生无效的画布区域
    3. 不支持音视频、gif 图

其次对于 Dom 来说,

  • 优点

    1. 可以利用 css,元素样式控制方便
    2. 调试方便,可以直接在控制台抓到元素
    3. Dom API 更加完善便捷
  • 缺点

    1. 元素多时性能开销大
    2. 对于不规则图形实现麻烦

那么结合他们各自的优缺点,我们并没有单纯的选取某一种方案,而是把二者结合起来,也就是说两种我们都用了!下面我们来介绍是如何结合使用的。

画布元素 vs 外挂元素

回头再来看一下我们的元件系统,我们可以分为两类,一类是 Canvas 支持的,另一类是不支持的,想必大家也猜到了,对于 Canvas 不支持的元件我们使用了 Dom,总结一下:

  • 使用 Canvas 的元件(画布元素):文本、图形、图片(静态图片)
  • 使用 Dom 的元件(外挂元素):音频、视频、Gif 图、iframe

那么二者在同一个画布上是怎么结合起来的呢?借助下面这个截图来解释一下

原理其实不难,如果我要添加一个外挂元素(音视频、gif、iframe)在画布上,那么在编辑的时候我会把它当做图片来处理,也就是说,用一个图片来在 Canvas 上做占位,我们可以在画布上随意缩放,旋转等,其次我还会同步渲染一个 video 元素盖在画布上层,并且把 canvas 元素的属性翻译成 css 属性,下面列出段伪代码:

/**
 * klass:充当外挂元素的画布元素
 *
 * */
export function getHtmlElement(klass, i, option = {}, evn = 'editor') {
  const basicStyle = {
    display: klass.visible ? 'block' : 'none',
    position: 'absolute',
    transform: `rotate( ${klass.angle}deg )`,
    ...klass.getBoundingRect(),
    // ... 其他公共属性
  };
  switch (klass.type) {
    case 'gif':
      if (klass.angle) {
        const canvasZoom = this.canvas.getZoom();
        const width = (klass.width + klass.strokeWidth) * canvasZoom * klass.scaleX + 1;
        const height = (klass.height + klass.strokeWidth) * canvasZoom * klass.scaleY + 1;
        Object.assign(basicStyle, {
          left: klass.left * canvasZoom - width / 2,
          top: klass.top * canvasZoom - height / 2,
          width: width,
          height: height
        });
      }
      Object.assign(basicStyle, { pointerEvents: 'none' });
      return (
        ![]({klass.getSrc()} />)
      );
    case 'video':
      return(
        <video

          style={Object.assign(basicStyle, { width: basicStyle.width + 1, height: basicStyle.height + 1 })}
          src={klass.videoUrl}
          // ...其他 video 属性
        />
      )
    case 'iframe':
      return <iframe key={klass.id} className="el-iframe" src={klass.iframeUrl} style={basicStyle} />;
    case 'audio':
      // ...
  }

课件数据结构

大家知道,对于这种富前端应用来说,存储的数据会相当大。以我们的课件系统举例,画布上每个元素都会有 20-30 个属性,一页课件上可能会有数十甚至上百个元素,每个课件大概会有 15-30 页不等,一讲课件产生的课件数据至少要在 1M 以上(课间数据是指对于课件的描述数据,比如元素的位置,课件的页码,题型等,不包括静态资源)。所以对于我们来说,如何组织这些数据变的尤为关键,组织不好会对后期维护以及性能造成很大的影响。

在设计数据存储结构之前,要考虑清楚目标,那么我在设计之前大致考虑了两点:

  • 尽可能让数据小
  • 数据结构清晰、简单

结合我们的场景举个例子,我们要执行下面一系列操作:

  1. 在画布上添加两个元素:圆形 A,方形 B,
  2. 给 B 添加一段折线动画
  3. 给 A 绑定一个点击事件,让点击 A 的时候,B 播放折线动画

那么这个场景一般情况下我们可能会把数据设计成这样:

const data = [
  {
    id: "elementA",
    name: "圆形",
    left: 100,
    top: 100,
    event: {
      type: "click",
      target: {
        id: "elementB",
        // 其他属性
      },
    },
  },
  {
    id: "elementB",
    name: "方形",
    left: 100,
    top: 100,
    event: {
      type: "click",
      target: {
        id: "elementB",
        left: 150,
        top: 150,
        vfx: [
          //动画数据
          { name: "point1", left: 20, top: 20 },
          { name: "point2", left: 50, top: 50 },
          // ...
        ],
      },
    },
    // 其他属性
  },
];

这样如果数据量小的时候,是没有问题的,获取数据简单,方便我们开发。但如果数据量多的时候,缺点就会突显:这种嵌套结构,会使数据量变大,层级过深不易维护。

真实情况我们是这样处理的,类似数据库一样,我们在前端设计了几张表:元件表,动画表,事件表,题型表等。 表与表之间用 id 做关联(主键),数据结构类似下面这样:

const data = {
  // 元件表
  levelList: [{ id: "element1", name: "元件1",left: 10,top: 10}],
  // 动画表
  vfxData: [{ id: "vfx1", target: "element1", path: [] }],
  // 事件表
  actionData: [{ id: "action1", target: "element1", type: "click" }],
  // 题型表
  activityData: [{ id: "activity1", target: "element1", source: 'element2' type: "fill" }],
};

这样我们可以更清晰的看到这页数据,都有哪些元件、动画、事件及题型,通过 id 关联也一定程度的减少了数据的大小(对于减少数据体积的问题,我们在序列化的时候,还会过滤掉一些框架提供的无用属性)。当然这样做也是有缺点的,比如在删除某个元件的时候,我们需要额外处理相关的表中的数据,这需要我们在代码中封装出相应的方法。

播放器

在说播放器之前,还是回顾一下上面那张图,我们的播放器会在多个场景下使用,有线上课、线下课以及其他一些业务系统中。出于这些考虑,我们把核心播放器抽离成了公共组件,每个使用方在播放器组件的上层去做定制化的功能,那么下面我们首先来说这个核心播放器组件。

核心播放组件

首先我们来看下组件的调用方式很简单,类似这样:

<CourseWarePlayer
  defaultPage={pid}
  data={coursewareData}
  onPageChange={(page, currentData) => {
    this.setState({ currentPlayPage: page, notes: currentData.notes });
  }}
  options={{
    video: {
      controlsList: "nodownload",
    },
  }}
  extraElements={
    <Fragment>
      <ClassroomWrapper
        onClose={this.onToggleClassroom}
        scale={this.state.canvas.getZoom()}
      />
    </Fragment>
  }
  onQuestionCommit={this.onQuestionCommit}
  // ... other props
/>

组件内部包含了数据处理,课件、题型、动画等课件内容的展示,以及答题结果展示、处理回调等功能。各个使用方在调用的时候只需要传入指定格式的课件数据,课件就可以渲染出来了。以下我来介绍几个与其相关的技术点。

实现线上实时互动

实时互动的意思是老师和学生都可以操作课件,并且相互能够看到,一般实现这种需求有两种方式,一种是直接录屏直播,学生能够保证看到老师所有的交互,但如果想让学生和学生之间互动就比较难实现了;另外一种是所有用户都打开课件,类似在线游戏,通过传递消息来实现同步,我们目前使用的就是这种。

实现这功能,重点需要处理状态同步。 说起来容易做起来其实挺费劲的,细节比较多,列举几个问题,大家也可以思考如何实现:

  1. 学生中途进课的时候,课件如何处理?
  2. 上课过程中把程序切到后台,如何处理?
  3. 丢包的时候如何补偿?
  4. 如何让课件秒翻页?
  5. 音视频状态如何做到同步?
  6. 课件中答题步骤如何做到同步?
  7. 动画如何同步?

除去后端相关的内容,我们直接说课件端主要需要做哪些工作,说 3 点关键的功能点:

  1. 操作回调
    根据我们的课件特点,操作大致又可以分为两类,画布元素操作以及外挂元素操作,画布元素上的操作我们借助 fabric.js 很容易捕获到,外挂元素就会稍微复杂一些了,主要针对音视频,需要我们手动去绑定对应的事件了,比如 video.addEventListener('play', this.update);
  2. 发送、接收操作消息
    通过操作回调,那么组件外层会获取到相应的状态改变,我们通过 websocket 去发送消息。这里我们需要注意尽可能的让传输数据更小。比如如果一个元素的位置发生改变,我们只需要发送idlefttop 数据
  3. 实现组件受控
    实现组件受控其实是难点,与第一条类似,其中细节非常多,不过还是可以分为画布元素和外挂元素来考虑,画布元素大体上可以通过 fabric.js 中 API LoadFromJSON 可以实现,而外挂元素就需要我们去手工封装音频、视频受控组件去处理了。此外还有答题步骤等,这里我就不详细描述了。

迄今为止,我们对于 iframe 以及 Gif 图的状态同步还没有实现,如果您有解决方案,期待您的不吝赐教。

性能及线下离线方案

前面有说过,性能对于播放器而言也是个比较大的挑战。对于性能优化,我们的工作分为两部分:播放器组件和使用方。组件内部的优化点相对比较零散,网上前端性能优化的方案也很多,我们基本上也是从那些方面做优化,我简单列举几点,这里我不详细介绍:

  1. 去除对游戏引擎的依赖,动画改为自己实现,这样能够大幅度的减小 js 体积
  2. 预加载,提前两页去预加载一页的静态资源(图片,视频,音频)
  3. 对于图片,优先使用 webp;其次借助阿里云 oss 的功能,我们会针对不同尺寸的设备加载不同尺寸的图片
  4. 字体文件、固定图片的合并压缩
  5. cdn,开启国际加速,上课前提前预热
  6. webpack 打包优化
  7. 资源多域名
  8. ... ...

下面我重点来说一下我们的线下课离线方案。

离线,顾名思义就是不依赖网络可以正常播放课件,之所以要做离线主要出于两点考虑:

  • 应对网络、服务不可用等突发问题
  • 资源完全本地化,能够大幅度提升性能体验

如何实现呢?我们利用 Electron + 校区公盘实现了资源本地化的功能。 具体实现流程我们来看下面这张图,包含了我们资源整个的生命周期:用户上传-> 资源加密 -> 同步到校区公盘 -> 播放课件 -> 获取课件数据 -> 本地化资源 -> 资源解密 -> 渲染课件

基于这套方案我们基本上可以做到课件本地化,之前测试过,课件中一个 150M 的视频文件,基本可以在 2 秒之内完全缓冲完。

放在最后

其实完整的课件系统还衍生出诸多周边辅助产品,我们也不例外,会有很多辅助工具,比如:

  • 快速导出 ppt 工具
  • 静态资源导出工具
  • 课件数据批量修复工具
  • 客户端文件检查工具
  • 课件数据可视化编辑工具

以上就是我对于励步课件系统中比较重要的几个功能点的简介,希望对大家有所帮助,其实还有很多细节问题一篇文章讲不清楚,如果有什么问题或者指导欢迎知音楼联系 郑庆鑫,或者加微信 zqx362965772


LeapFE
1.1k 声望2.3k 粉丝

字节内推,发送简历至 zhengqingxin.dancing@bytedance.com