前言
想必很多“投身于教育行业”的前端工程师们都绕不过“课件”这个话题,对于前端来说,课件项目是教育公司相比互联网公司特有的需求之一,对于公司来说也是及其重要的。目前教育行业我了解到的生产 h5 课件的方式大致分为以下三种,每种方式也是各有优劣,下面是我的理解:
- ppt 制作,通过三方或者自研平台转换成 h5
这种方式可能更适合初创团队或者是开发资源较少的团队,而且一般配合着其他教学服务一起使用(比如直播),这样几乎可以完全不需要技术人员的支持,当然劣势也是明显的,首先这一般要依托于三方平台,其次是编辑端一般需要在 ppt 上完成,文件易丢失和泄漏。 - 研发手写课件,比如用 cocos creator 根据教研老师提供的 ppt 生产课件
这种”流水线“是比较主流的方式之一,往往通过研发手动编写出的课件更加灵活,效果更好更生动,能够完成更复杂的课件。缺点想必很多伙伴也是深有体会,这种方式人力成本巨大,要有一支固定的游戏开发团队,需要教研、设计、研发、测试共同协作才能完成一讲课件。 - 提供编辑平台,教研在此平台直接制作并输出 h5 课件
第三种方式也是比较主流的方式之一,据我所知目前也有很多团队在从第二种方式转到这种方式。这种方式一般是由开发提供课件平台,教研老师自己在平台上制作课件,这样一定程度的释放了开发人员,降低了协作成本,开发课件效率更高,周期更短。当然它也是有劣势的,一般这种编辑平台的开发难度比较高,其次就是前期教研老师对于这个平台的学习成本也不小。
有时候对于团队来说,三种方式不是互斥的,大部分情况三种方式是并行的,会根据内容的类型、复杂度等方面去折中选择。对于我们团队来说,其实也是三种共存的,不过大部分内容生产使用第三种方式,下面我来给大家介绍一下励步课件的技术体系。
业务场景分析及技术难点
介绍技术之前,首先我来简单介绍下我们课件使用的业务场景:
- 我们的课件分为两种,线上课件和线下课件,两种课件在内容相互衔接,风格没有明显差异,有区别的是线上课件需要做实时的师生互动,分为师生两端,教师用 PC 端,学生 ipad 居多,线下课主要是线下校区使用利用白板播放,线下除了课件播放,还需要一些辅助教学的功能。
- 支持了英语、数学、语文三个学科
- 生产线上课件与线下课件是同一个部门的教研老师。
其次我们再来结合业务看下我们要面临的技术难点:
- 性能要求高:对于上课的产品,要求是完全接近离线的体验,举例来说,图片、音视频等资源不允许出现缓冲等待;
- 容错要求高:灾备方案要考虑,比如断网,课件也需要能够正常播放;
- 风险高:对于上课场景来说,基本上一两分钟的系统不可用都是不能忍受的;
- 交互复杂:最后一点主要针对编辑端,做过编辑器的伙伴大致都了解,能够实现 ppt 那种功能交互是异常复杂的。
那么结合以上的业务场景和遇到的困难,我来给大家一一介绍我们的处理方式,在此之前为了让大家更好的理解,我先放几张我们整体的功能模块图以及功能演示图。
功能模块图:
课件编辑器:
课件播放器:
编辑器
目前我们的编辑器大致可以分为以下几大模块的功能:
- 元件系统:这也是我们编辑器的核心功能,支持添加文字、图形、图片、音频、视频以及 iframe,每种元件都支持若干种属性的更改。
互动系统:这其中还分为事件、动画、题型三个模块。
- 事件模块:我们可以给元件做事件绑定操作,比如我可以给某个元件设置点击事件,点击后隐藏某个另外的元件,或者播放动画、播放音视频,再或者跳转到某一页等等。
- 动画模块:我们支持自定义动画功能,当前支持折线动画,可以设置播放时长、循环播放等属性
- 题型模块:课件中可以设置拖拽,选择,连线等题型
- 通用模块:包括了基础的功能,比如复制粘贴元件,组合,撤销,图片裁剪,帧动画制作,资源库,页码操作等等
那么在实现以上功能的时候,有几个关键的技术点与大家分享。
Canvas vs Dom
相信如果你也做过类似的产品,在初期做技术调研的时候一定也会在 Canvas
和 Dom
之间纠结,实际上用两种方式都可以实现这类功能,市面上也都有成功的案例,但我们在开始之前还要针对我们的业务场景来综合评估,首先我们来梳理一下这两种方式的优缺点:
首先对于 Canvas 来说,
优点
- 元素多的情况下,性能表现更好
- 不需要过多考虑重绘的问题
- 对于图片处理更加方便
- 三方资源较多
缺点
- 上手门槛较高
- 元素少的时候会产生无效的画布区域
- 不支持音视频、gif 图
其次对于 Dom 来说,
优点
- 可以利用 css,元素样式控制方便
- 调试方便,可以直接在控制台抓到元素
- Dom API 更加完善便捷
缺点
- 元素多时性能开销大
- 对于不规则图形实现麻烦
那么结合他们各自的优缺点,我们并没有单纯的选取某一种方案,而是把二者结合起来,也就是说两种我们都用了!下面我们来介绍是如何结合使用的。
画布元素 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 以上(课间数据是指对于课件的描述数据,比如元素的位置,课件的页码,题型等,不包括静态资源)。所以对于我们来说,如何组织这些数据变的尤为关键,组织不好会对后期维护以及性能造成很大的影响。
在设计数据存储结构之前,要考虑清楚目标,那么我在设计之前大致考虑了两点:
- 尽可能让数据小
- 数据结构清晰、简单
结合我们的场景举个例子,我们要执行下面一系列操作:
- 在画布上添加两个元素:圆形 A,方形 B,
- 给 B 添加一段折线动画
- 给 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
/>
组件内部包含了数据处理,课件、题型、动画等课件内容的展示,以及答题结果展示、处理回调等功能。各个使用方在调用的时候只需要传入指定格式的课件数据,课件就可以渲染出来了。以下我来介绍几个与其相关的技术点。
实现线上实时互动
实时互动的意思是老师和学生都可以操作课件,并且相互能够看到,一般实现这种需求有两种方式,一种是直接录屏直播,学生能够保证看到老师所有的交互,但如果想让学生和学生之间互动就比较难实现了;另外一种是所有用户都打开课件,类似在线游戏,通过传递消息来实现同步,我们目前使用的就是这种。
实现这功能,重点需要处理状态同步。 说起来容易做起来其实挺费劲的,细节比较多,列举几个问题,大家也可以思考如何实现:
- 学生中途进课的时候,课件如何处理?
- 上课过程中把程序切到后台,如何处理?
- 丢包的时候如何补偿?
- 如何让课件秒翻页?
- 音视频状态如何做到同步?
- 课件中答题步骤如何做到同步?
- 动画如何同步?
除去后端相关的内容,我们直接说课件端主要需要做哪些工作,说 3 点关键的功能点:
- 操作回调
根据我们的课件特点,操作大致又可以分为两类,画布元素操作以及外挂元素操作,画布元素上的操作我们借助fabric.js
很容易捕获到,外挂元素就会稍微复杂一些了,主要针对音视频,需要我们手动去绑定对应的事件了,比如video.addEventListener('play', this.update);
- 发送、接收操作消息
通过操作回调,那么组件外层会获取到相应的状态改变,我们通过 websocket 去发送消息。这里我们需要注意尽可能的让传输数据更小。比如如果一个元素的位置发生改变,我们只需要发送id
,left
,top
数据 - 实现组件受控
实现组件受控其实是难点,与第一条类似,其中细节非常多,不过还是可以分为画布元素和外挂元素来考虑,画布元素大体上可以通过fabric.js
中 APILoadFromJSON
可以实现,而外挂元素就需要我们去手工封装音频、视频受控组件去处理了。此外还有答题步骤等,这里我就不详细描述了。
迄今为止,我们对于 iframe 以及 Gif 图的状态同步还没有实现,如果您有解决方案,期待您的不吝赐教。
性能及线下离线方案
前面有说过,性能对于播放器而言也是个比较大的挑战。对于性能优化,我们的工作分为两部分:播放器组件和使用方。组件内部的优化点相对比较零散,网上前端性能优化的方案也很多,我们基本上也是从那些方面做优化,我简单列举几点,这里我不详细介绍:
- 去除对游戏引擎的依赖,动画改为自己实现,这样能够大幅度的减小 js 体积
- 预加载,提前两页去预加载一页的静态资源(图片,视频,音频)
- 对于图片,优先使用 webp;其次借助阿里云 oss 的功能,我们会针对不同尺寸的设备加载不同尺寸的图片
- 字体文件、固定图片的合并压缩
- cdn,开启国际加速,上课前提前预热
- webpack 打包优化
- 资源多域名
- ... ...
下面我重点来说一下我们的线下课离线方案。
离线,顾名思义就是不依赖网络可以正常播放课件,之所以要做离线主要出于两点考虑:
- 应对网络、服务不可用等突发问题
- 资源完全本地化,能够大幅度提升性能体验
如何实现呢?我们利用 Electron + 校区公盘实现了资源本地化的功能。 具体实现流程我们来看下面这张图,包含了我们资源整个的生命周期:用户上传-> 资源加密 -> 同步到校区公盘 -> 播放课件 -> 获取课件数据 -> 本地化资源 -> 资源解密 -> 渲染课件
。
基于这套方案我们基本上可以做到课件本地化,之前测试过,课件中一个 150M 的视频文件,基本可以在 2 秒之内完全缓冲完。
放在最后
其实完整的课件系统还衍生出诸多周边辅助产品,我们也不例外,会有很多辅助工具,比如:
- 快速导出 ppt 工具
- 静态资源导出工具
- 课件数据批量修复工具
- 客户端文件检查工具
- 课件数据可视化编辑工具
以上就是我对于励步课件系统中比较重要的几个功能点的简介,希望对大家有所帮助,其实还有很多细节问题一篇文章讲不清楚,如果有什么问题或者指导欢迎知音楼联系 郑庆鑫
,或者加微信 zqx362965772
。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。