有道技术团队

有道技术团队 查看完整档案

北京编辑  |  填写毕业院校网易有道  |  技术团队 编辑 shared.youdao.com/www/about.html 编辑
编辑

公众号:有道技术团队

网易有道是中国领先的智能学习公司,致力于提供100%以用户为导向的学习产品和服务。
旗下有网易有道词典、有道精品课、有道云笔记、有道翻译官等多款深受用户喜爱的产品。

个人动态

有道技术团队 关注了用户 · 1月21日

卤代烃 @skychx

公众号:卤蛋实验室

关注 89

有道技术团队 发布了文章 · 1月21日

有道云笔记新版编辑器架构设计(上)

在开发有道云笔记的新版编辑器的过程中,我们遇到很多实际问题,愈发感觉到这是一个非常有深度的前端技术领域,所以我们将新版编辑器的技术选型、架构和部分实现细节拿出来分享给大家,希望对大家开发富文本编辑器、做复杂系统的架构设计有一定参考意义。

作者/ 金鑫

编辑/ Ryan

来源/ 有道技术团队(ID: youdaotech)

1. 富文本编辑器背景

1.1 什么是编辑器

编辑器在前端开发领域是指可以提供给用户编辑纯文本、富文本、代码、多媒体内容等的功能模块,例如以云笔记为例,编辑器指下图中绿色的区域。

有道云笔记编辑器界面

编辑器一般由编辑区域、光标、工具栏、右键菜单等功能模块组成,一般都包含编辑文字、设置文字样式、设置段落样式、插入多媒体内容、撤销重做、复制剪切粘贴等功能。

1.2 编辑器发展简史

编辑器的由来可以追溯到打字机时代,下图是一个常见的打字机。

我们可以将打字机的构造与编辑器进行类比,打字机的纸张对应于编辑器的编辑,打字机的游标对应于编辑器的光标,甚至敲击键盘的表现,编辑器也与打字机一脉相承:

  1. 当敲击字母时,在光标后输入该字符;
  2. 当敲击空格键时,在游标之后插入空格;
  3. 当敲击回格键时,删除游标之前的字符;
  4. 当敲击换行键时,游标换到下一行开始;

而在计算机中,出现的最早的是文本编辑器,例如我们在 Linux 系统中常用的 vi,vim,emacs 等,它们可以对纯文本数据进行编辑,并引入了撤销重做、复制剪切粘贴、查找替换等编辑器的核心功能。

随着用户图形界面的兴起,人们对于文本的编辑不止满足于纯文本了,还需要给文本段落加上各种格式和排版信息。

同时,人们对于在文档中插入图片、图形、表格等更丰富格式的需求也越来越多。为了满足这些需求,富文本编辑器就出现了,其中的集大成者就是微软的 Word 和金山的 WPS。

Word 和 WPS 可以说将桌面客户端中的富文本编辑器做到了极致,至今也是功能强大的富文本编辑器。

但是它们的设计初衷就是做一款单机的文字处理软件,自然会遇到不支持互联网上的音视频格式、存储备份依靠本地计算机的文件系统、多人协作依靠文件拷贝等问题。

在互联网遍及千家万户的今日,人们反而不太需要 Word 提供的交互复杂的各种强大功能,而是需要支持更多互联网数据格式、存储备份更加方便、能够提供多人协同编辑功能的轻量级富文本编辑器。

基于浏览器的富文本编辑器就是在这样的设计思路下产生的,其中的代表产品有 Google Docs、有道云笔记、印象笔记、石墨文档等。

这些基于浏览器的富文本编辑器都有以下特点:

  1. 利用 Web 技术开发,需要在浏览器环境中使用;
  2. 功能相对 Word 更加简单,只保留了最常用的富文本编辑功能;
  3. 支持图片、附件、视频、音频、地图等多种互联网资源;
  4. 可以将文档备份在网盘中,实现多端同步;
  5. 文档可以分享查看,可以进行多人实时协同编辑。

当然基于浏览器的富文本编辑器,也是经过了几轮的技术迭代和创新,才到了今天这种百花齐放的局面。

1.3 基于浏览器的富文本编辑器四要素

在现代的浏览器框架下,利用 Web 技术开发一款富文本编辑器,一般采用经典 MVC 模型,根据数据模型渲染视图,视图操作通过控制器修改数据模型。具体要解决以下四个问题:

模型:

模型包含内存模型和存储模型。存储模型是数据存储、同步和备份时的模型,需要考虑带宽、存储体积、模型序列化效率、模型正确性验证效率等因素。内存模型则是数据渲染时的模型,结构一般比存储模型复杂,会在存储模型的基础上添加其他渲染时需要用到的属性。

渲染:

渲染指如何将内存模型渲染成 Web 页面。所有的基于浏览器富文本编辑器都将内存模型渲染成为了 HTML 页面。但是它们在排版上的策略略有不同,大多数编辑器都采用了基于 HTML 和 CSS 的排版方式,也有少数编辑器自己实现了排版引擎,例如 Google Docs。

编辑:

编辑指如何提供编辑区域让用户在编辑区域编辑文档,以及如何感知用户编辑区域的编辑动作通知控制器以修改数据模型。浏览器提供了 contentEditable 的属性可以把元素变为可编辑状态,大部分编辑器都是以这个思路进行编辑的,并且它们可以拦截 contentEditable 元素的事件,将事件通知给控制器。也有少数编辑器自己实现了编辑区域和事件系统,例如 Google Docs。

指令:

指控制器根据收到的编辑区域的编辑动作,生成对应指令修改内存模型,内存模型得以更新完成循环。这部分与数据模型相关,如果数据模型是 HTML,编辑器可以通过 execCommand 直接修改 HTML 数据,如果是自定义数据,监听或拦截编辑区域上的事件,也可以推测意图生成出修改数据模型的指令。通过指令修改数据,可以更方便的实现撤销重做、历史版本恢复、协同编辑等功能。

1.4 基于浏览器的富文本编辑器技术演进

基于以上四个问题,可以将基于浏览器的富文本编辑器划分为四代:

第一代:

完全基于浏览器 API 设计,数据模型直接采用 HTML 数据,渲染用原生 HTML,编辑区域用 contentEditable 生成,通过 execCommand 执行浏览器自带的修改 HTML 数据的指令。

这类编辑器一般都出现在各类《XX 行代码教你实现富文本编辑器》的博客里,基本没有成熟的开源编辑器或者商用编辑器采用这一种设计方式。它们的主要问题处在 execCommand 接口上:

  • 只提供了有限的几个命令,例如 execCommand 就没有办法支持插入待办列表。
  • 提供的命令有些有功能受限,例如 'fontSize' 命令只能支持 1-7,导致不能自定义字体的大小。
  • 修改的结果与浏览器有关,例如 'bold' 命令在一些浏览器上会给选区中的文字添加标签,而在另一些浏览器上则会添加标签。

第二代:

由于 execCommand 功能上的限制,第二代的编辑器普遍抛弃了用浏览器的 execCommand 接口直接修改 HTML 文档的办法,而是用自己实现的 execCommand 和指令修改 HTML 文档,这样做就可以实现更加灵活多样的功能。

这类编辑器的主要问题是:不同的 HTML 结构可能表示的含义一样。例如下面两行 HTML 都表示一段既加粗又斜体的文字,但是他们的 HTML 结构却不一样,这样对比较数据是否一样就变得非常困难。

第三代:

针对 HTML 含义不一致的问题,第三代编辑器则抛弃了既用 HTML 做文档模型,又用 HTML 做渲染的策略,而是采用自定义的数据模型,例如 XML 数据模型或者 JSON 数据模型。同样的数据模型渲染生成的 HTML 一样,自定义的操作则可以保证同样的操作修改之后的文档模型也是一样的。

目前常见的编辑器产品例如:有道云笔记、石墨文档等,以及开源的编辑器库例如 Slate、Draft、Quill 等,都是第三代编辑器,它们已经能够满足大多数应用场景。但是由于渲染出的页面中,可编辑区域还是基于 contentEditable,需要根据拦截的事件判断用户行为,生成对应的指令修改数据模型。一旦有用户数据没有拦截,或者处理的行为不对,用户的行为就可能直接修改了 contentEditable 的元素,导致数据和视图的不一致。因此产生的 bug 定位和修复都比较难,在编辑器的移动端适配中经常出现。

第四代:

为了解决 contentEditable 引起的不可控事件,以 Google Docs 为代表的第四代编辑器则彻底抛弃的 contentEditable,自己实现排版引擎。排版引擎控制了文档的页面和布局,将数据渲染成为页面上的HTML。同时由于抛弃了contentEditable,还需要解决实现光标和选区的绘制、监听文字输入事件等技术问题,才可以做到和浏览器类似的编辑体验。

第四代编辑器相对于第三代,优点是彻底解决了contentEditable 引起的 bug,有更好的可扩展性。对应的代价是开发难度更高、体验不如原生、可能会遇到性能问题。目前只有 Google Docs 等采用了这种架构开发编辑器。

2. 云笔记新版编辑器技术选型

根据上节所述的实现富文本编辑器的四要素,我们总结了四代编辑器的技术选型,如下表所示:

有道云笔记新版编辑器综合了项目的可扩展性和实现难度,作出了以下的技术选型:

  • 模型:自定义的 JSON 数据格式作为内存模型,它的压缩版本作为存储模型;
  • 渲染:借助浏览器排版,用 React 框架渲染视图;
  • 编辑:不依赖 contenteditable,拦截浏览器事件判断用户交互,自己实现了光标和选区;
  • 指令:实现了丰富的自定义的富文本编辑指令,重新实现了 execCommand 执行指令。

2.1 模型

有道云笔记新版编辑器采用了自定义的 JSON 数据格式作为内存模型。存储模型与内存模型基本对应,是内存模型的压缩版本,这样可以减少在数据序列化和反序列化过程中出现的错误。

文档模型:

新版编辑器的内存模型采用了文档-段落-文本的三层模型,顶层对象是文档(下图黄色区域),一篇文档包含多个段落(下图蓝色区域),每个段落中有至少一个文本(下图红色区域)。

对于三层的文档模型,我们可以很自然的想到用树的结构来表示它,如下图所示:

由于 JSON 格式天然的就可以表示嵌套的树状结构,所以我们的三层文档模型可以表示为以下的 JSON 结构:

富文本表示:

对于富文本编辑器的数据模型,需要考虑文本的行内样式和段落样式:

行内样式是作用于文字的样式,每个文字都可能有不同的行内样式,例如文字的粗体、斜体、文字颜色、背景色、字体、字号等

段落样式是用作与段落的样式,整段文字只会有一个段落样式,例如对齐方式、行高、段落缩进等。

由于我们三层文档模型中段落是单独一层,有对应的段落节点,所以对于段落样式只需要在段落节点上添加表示段落样式的字段,我们是在 paragraph 添加了 data 字段,其中添加了 style 属性表示该段落的段落样式,如下图所示:

如果要表示行内样式,目前文本节点中只保存一个 content 字段就没有办法胜任了,需要将它拆分为一个一个字符在每个字符上添加表示行内样式的字段,例如我们可以用一个 chars 数组,里面的每个元素表示一个字符,text 字段表示字符的内容,marks 数组表示字符上的行内样式,如下图表示 a rich text。

叶子节点:

上述的富文本行内样式的表示方法中,我们可以看到rich的样式粗体、斜体、红色背景色保存在了 r, i, c, h 四个字符上,存在着冗余数据。我们参考了HTML渲染结果和部分开源编辑器的实现定义了合并规则。

如果连续的字符节点,具有完全相同的行内样式,则它们可以合并成一个叶子节点。

例如上面的例子里,r, i, c, h 四个字符连续且具有完全相同的行内样式,它们是可以合并成为一个叶子节点的。类似的 “a ”、“ text” 也分别可以合并成叶子节点,所以我们可以将文本节点简化表示为:

综上, 简化后支持行内样式的文本节点也是一个树状结构,它包含一个或多个叶子节点,每个叶子节点包含文本的内容和这些内容共同的行内样式,且相邻的叶子节点的行内样式必须不完全一致,如下图所示:

2.2 渲染

云笔记新版编辑器依旧采用了浏览器进行排版渲染,没有像 Google Docs 那样自研排版引擎,原因是我们认为浏览器的排版引擎已经足够强大,基本满足日常的文本编辑需求,只有诸如图片的文字环绕、分页、分栏等比较高级的功能无法实现,而自研排版引擎需要大量的开发和测试工作量,还可能会产生性能问题,所以我们暂时还是用来浏览器进行排版和渲染。

我们采用了 React 框架,采用了组件化的方式渲染数据模型。针对文档-段落-文本的三层数据模型,以及文本节点的叶子模型,我们设计了对应 React 组件嵌套进行渲染,如下图所示:

  • Document 组件渲染文档数据。
  • Paragraph 组件渲染段落数据,它是 Document 组件的子组件。
  • Text 组件渲染文本数据,它是 Paragraph 组件的子组件。
  • Leaf 组件渲染叶子节点,它是 Text 组件的子组件。

对于叶子节点包含的文本片段和行内样式,只需要渲染成一个带 style 属性的<span>标签就可以了,这也印证了我们设计的富文本模型,简化了富文本的渲染逻辑,使得富文本渲染代码变得非常的轻量化。

以上是本期文章的全部内容。
下周三,我们将推送《有道云笔记新版编辑器架构设计(下)》,继续分享关于编辑指令的内容,并进一步讲解新编辑器的分层架构。
敬请关注有道技术团队。

- END -

查看原文

赞 17 收藏 6 评论 4

有道技术团队 关注了用户 · 1月20日

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2148

有道技术团队 发布了文章 · 1月14日

硬件测试的思考和改进:有道词典笔的高效测试探索

作者/ 刘哲
编辑/ Ryan
来源/ 有道技术团队(ID: youdaotech)

引言

当我们提到智能硬件的高效测试时,通常会考虑使用自动化测试的方案,提升产品的测试效率和质量。

由于智能硬件的使用过程中,包括了大量和用户的行为交互,这就导致在测试方案上,传统的软件自动化测试很难完全模拟用户的完整使用行为。

因此,我们除了要考虑借鉴和使用软件测试的思路之外,还要考虑如何实现硬件测试自动化。

一、背 景

有道词典笔 2.0 是网易有道自研的学习型智能硬件。

有道词典笔搭载了有道自研的 OCR、NMT、TTS 技术,为用户提供了一扫查词、中英文互译、语音助手、触屏、离线等功能。

当我们拿到词典笔 2.0 第一个版本的时候,首先看到的是它的硬件外观:

从硬件层面来看——

它包括了一块可触摸的屏幕,接口方面使用了 Type-C 方案,在下方有一个摄像头,背面有喇叭可以发音,按键方面有开关机、功能键和笔的触头。

同时在设备的内部还内置蓝牙和无线模块。

这个产品如何使用呢?用户的典型使用场景是:

手持有道词典笔,向下按压笔头开启补光灯和摄像头,在文字上方滑动,实现对文字的拍照。之后图片合成,进入 OCR 模型,识别出文字后,进入 NMT 模型,最后翻译结果展示出来,进入 TTS 服务。

所以,简单来说,它是以扫描识别行为为基础操作,实现若干功能的一款硬件产品。

现在我们知道软件方面的能力了,这时候就可以结合硬件一起来考虑,有道词典笔的高效测试要怎么做。

二、让硬件动起来

我们对一款产品做自动化测试,首先要找到用户的主要使用路径。

用户花了最多的时间使用的行为,就是我们需要花精力去考虑如何模拟的行为。

很明显,在这里用户的扫描行为引发的查词和翻译学习结果。

那我们就来看看,用户的实际操作是如何的。

我们对用户扫描的场景模拟,可以分成两个部分。

一个部分是对词典笔的控制,稳定的握持,另外一部分是对笔的移动。
image
在考虑实现这样的方案时,我们考虑过市面上现有的自动化方案,来实现对笔的固定和移动。

但是碍于成本和可复制性并不合适,所以没有采用。

让词典笔从左向右移动起来这件事情,是整个行为的难点。

那是否可以让词典笔不动,也实现一样的扫描效果呢?

我们决定换个思路。

我们让笔不动,文字从右向左移动,从而模拟笔从左向右移动的效果。
image
为了可以持续的测试,还需要文字再从左向右回来,然后再次从右向左。

当它成为一个循环的时候,就实现了持续的文字移动。

大家看这样运动的文字像是什么?

我们的第一反应就是传送带,就是工厂里见到的流水线上的移动,所以我们做了第一套方案。
image
我们把文字固定在传送带上,然后用电机驱动传送带,实现了文字的持续移动。

当文字可以稳定移动之后,我们通过 shell 去控制词典笔的扫描行为,包括了开关笔头灯、开关扫描行为等等。

然后我们可以把设定时间内的扫描内容传送到笔内的翻译引擎中,进入后续的翻译和发音流程。

文字动起来了,那让词典笔固定就相对容易一些。

我们做的第一个尝试是使用市面上已经有的支架,把词典笔固定在支架上方,大家可以看下视频。

可以把笔夹住,直接固定在传送带上。
image

但是我们也发现了一个问题,支架每次只能固定一只笔,而且稳定性并不佳。

我们看这个视频也能看出来,一直在晃,这个效果只能说是能用。

而且我们刚才也提到了,在测试的过程当中,通常是需要让多支笔固定的。

所以我们尝试自己做了一个支架,把N支笔固定在传送带上方,这样,我们就可以实现用户扫描行为的完整模拟了。

这是我们对词典笔高效测试的第一次尝试,就是让硬件动了起来。

三、让方案更稳定

接下来我们要解决的问题是,让方案更稳定。

为什么有这样的需求呢?

在很长一段时间,我们都在使用上面提到的方案。

我们通过这套方案实现了功能稳定性的测试,对功耗以及电池曲线等都做了上百次的验证。

但是随着我们测试版本的增加,我们迭代的加快,自动化测试的需求更加频繁了。

在使用过程中,我们看到影响文字移动的稳定性,也就是传送带的稳定性因素是挺多的。

比如说电机老化,传动轴稳定性了,组装的精密程度,都可能会造成它文字转动时快时慢,甚至有的时候会停下来。

另外我们的前期一次可以去测试6支笔,但是到了后期,我们的测试版本的增加同时要测试的笔可能接近20支。

这个方案的改进就提上了日程。

我们还是分成文字的移动,以及笔的支撑两个部分来改进。

文字在垂直往复运动,这个方案我们使用的是传送带。

如果文字在水平方向往复运动呢?

我们观察了生活中很多的物品,最后发现小朋友喜欢的钓鱼玩具是一个不错的选择。

就是这个。

image.png

首先它的转速是恒定的,它在设计决定了影响它速度的因素,只是电机本身。

另外它的性价比比较高,这就使得我们可以快速的复制和扩充测试能力。

如果我们把文字在转盘边缘排列,然后允许运动,是不是就可以形成类似移动的效果呢?

为此,我们在文案上进行了一定的设计和改进。

我们把文字做了弧形的排版,固定在转盘上,在转盘的边缘固定测试笔,继续使用之前的自动化脚本。

这样就实现了用转盘的方案来实现扫描的行为模拟。
image.png

虽然它整体是个弧形的样子,但是得益于词典笔的算法优化,我们实际的拼图效果还是比较优秀的,对于测试测试没有什么影响。

最后我们去改装了它的供电方式为电源供电,这样它就可以长时间的做一个测试了。

接下来我们要解决的就是词典笔的支撑改进。

最开始是用硬纸来制作的简易的支架,这依旧是个扩展性不佳的方案。

随着我们的后期的调整和改进,我们在设计的同时帮助下,做了一个支架的改良版,通过建模和 3D 打印的方式就把它生产出来了。

这样的话它足够精密,同时它支撑10支笔的测试,而且他可以快速的复制,扩充给其他需要测试的场所。

这是我们实际 3D 打印出来之后的产品:

这就是我们在目前测试使用的方案,到这里我们看到整体硬件自动化已经比较稳定了。

四、让控制可远程

现在我们要考虑的事情,就是让控制可以远程。

我们在硬件测试上做的方案,原本都是在公司进行的,测试的设备也都是 QA 内部团队在用。

但是今年年初的疫情改变了我们的工作方式,在很长一段时间里我们都是在远程办公。

为了方便让开发人员更方便去调试,也为了让方便异地工作的同事们可以随时的进行测试,还有就是希望我们的测试方案的可控性更强,我们开始在做一些可控性方面的探索。

整体来说让控制可以远程这件事,我们分成了5个目标。

首先,是我们的测试脚本可以远程开启和关闭。

第二,是我们需要能够控制硬件的开关,主要是转盘的开关和供电系统的控制。

比如在静止的时候就可以开启转盘,测完之后就可以关闭转盘。测试功耗之前给词典笔充电,测试开始要断开供电。

第三,是需要满足开发人员在家进行远程调试的这样一个需求。

在家办公的时候,我们和开发不是同一个网络,甚至不是同一个城市,那开发如何快速进入词典笔内部进行调试呢?

第四,我们希望整个测试过程是可以被看到的,我们可以通过视频的监控来确定它的测试状态。

最后,由于测试自动化完成了大多数的项目后,我们需要对测试过程中的数据进行跟踪,测试过程中的数据保存与展示也不可获取。

所以基于这5个目标,我们设计了下面这套测试框架。

大家可以看到整个系统架构如下。

首先我们引入了一个主机或者叫控制系统,这里是用树莓派 4b 来做的。

在树莓派上我们连接了一个摄像机,采用了 mjpg-streamer 的方案,开了一个 web 的监控服务,这样测试人员可以随时去观察我们的词典测试的运行情况。

然后在树莓派的 GPIO 上,连了一个 L298n 的一个芯片,通过 python 我们可以使用芯片对电机开关和速度进进行控制。

之后我们又连接了一套继电器,用来对词典笔的通断电进行控制。

为了实现内网转发穿透的能力,我们搭建了一个 ngrok 服务,然后在测验词典笔启动它里面去,这样就可以从任何位置 ssh 到词典笔内部。

为了方便我们去观察数据和判断结果,我们使用了 influxdb 来保存测试中产生的数据,使用 grafana 来展示结果。

所以有了这样一套服务之后,开发产品和测试都可以实时的去用它。

这里我们有一个简单的演示视频。

我们外网通过 ngrok 服务远程,开启测试服务,然后开启转盘运转,这时测试开始。

测试中的视频就是通过树莓派的摄像头传输回来的。

当测试结束后,通过同样的方式,我们可以关闭或者继续进行其他的测试。

我们做了第三部分,让控制可以远程之后,我们基本上实现了一套框架。这套框架把我们用户最核心的操作,也就是扫描,变得可以自动化,可以远程控制,它可以稳定的远程控制。

五、让功能自动化

最后我们我们再来说说功能自动化的事情。

为了提升部分测试用例的自动化程度,我们还尝试做了一件事情,就是让功能自动化。

因为我们的产品是基于 Linux加QT 的架构实现的,为了提升它的测试效率,我们希望可以把核心软件功能自动化。

但是经过调研,市面上目前并没有足够成熟稳定的自动化测试方案适合我们。

我们通常说的自动化,大概的流程是先做控件的识别、再对控件做操作,然后对控件做校验。

在没有比较好的方案的前提下,我们用了一个“曲线救国”的自动化方案。

有道智云提供的 OCR 服务,可以针对图片上的文字进行识别。

它可以提取图片上的文字,并给出对应的坐标。

所以我们做的是:

01

用截屏加有道智云的 OCR 识别功能,实现了对文字的定位,代替了对控件的识别,例如“查词”,给出是否存在以及坐标位置。

02

用系统的操作,针对上面定位的坐标去点击、滑动等,实现了类似对控件的操作,例如点击“查词”的坐标。

03

最后我们还是用截屏,加上智云 OCR 识别,对页面的内容进行判断,例如对查词结果的验证。

这样就实现了基本的元素操作和控制。

我们就这样,把用户行为的自动化,设计并实现出来了。

下面的视频演示了我们采用该方案,全自动进行OTA升级测试的过程。

正常情况下,OTA测试需要一名全职测试工作8小时,来完成30轮次升级的验证。

有了自动化的方案,这个过程实现了无人值守测试,每晚可以实现50-100轮次的验证,第二天测试人员只需要检查测试过程的记录即可。

六、总结

经过上面的这些步骤,我们基本上对有道词典笔 2.0 的用户最核心操作——扫描后划词翻译,实现了软件和硬件方面的自动化。

通过硬件和软件结合的自动化方案,我们得到的收益巨大:

>>大幅提升了测试效率:

单个版本需要120+小时的测试数据,包括但不限于功能测试、主功能稳定性测试、随机稳定性测试、功耗测试、充放电电池曲线测试、耳机稳定性测试、耳机兼容性测试、OTA 测试等等;这些测试85%可以通过以上的测试框架来自动测试,我们单个版本的测试只需要2-3天,1-2人即可完成。

>>明确了产品质量:

我们针对每一项测试设计了不同的质量指标(例如功耗分成12种场景,测试时在不同场景下进行验证),得出的结果和之前版本或者竞品进行对比,从而判断我们产品的质量好坏。一个版本会有上百个指标,而这些指标就告诉了我们产品是否可以上线,产品的质量到底如何。

>>帮助发现硬件生产过程中的质量问题:

某个版本在两个批次的硬件测试中,同样的测试脚本和测试方案,测试出来的数据差异明显。通过多轮次的验证,对硬件的拆解等判断,最终定位某电阻混件造成了差异,由于我们提早发现了这个问题,尚未完成生产的产线停工,避免了更大的损失。

—— END ——

查看原文

赞 1 收藏 0 评论 0

有道技术团队 关注了用户 · 1月14日

robin @robin_ren

前端开发一枚,做过RN、小程序。

关注 47

有道技术团队 发布了文章 · 1月8日

技术杂谈 | Flutter 的性能分析、工程架构与细节处理

出品/ 有道智云
编辑/ Ryan
来源:有道技术团队(ID:youdaotech)

一、为何 Flutter

跨端技术众多,为何选择 Flutter?它能带来哪些优势,有哪些缺点?

先看看具体的工程效果:

Flutter 工程效果​v.qq.com

web 端效果体验:

flutter_exercise

1.1 Flutter VS 原生

无论如何,原生的运行效率毋庸置疑是最高的,但是从工程工作量的角度来对比的话,特别是快速试错和业务扩展阶段,Flutter 是目前为止比较推荐的利器。

1.2 Flutter VS Web

任何跨端的技术都是基于一码多端的思维,解决工程效率的问题,之前很多的跨端技术,例如 React Native 等都是基于web的跨端性解决方案,但是大家都知道,web 在移动端上的运行效率和 PC 上有巨大差距的,这就导致 RN 不能很有效地在移动端完成各种复杂的交互式运算(例如复杂的动画运算,交互的执行性能等),即便是引入了 Airbnb 的 Lottie 引擎依然会在低端的手机上面显得很卡顿(当然也可以使用一些自研的引擎技术来针对各端来解决,不过这样就失去了跨端的意义)。

1.3 Flutter 性能

Flutter 的编译方式和产物是决定其高效运行效率的前提,不同于 web 的跨端编译一样(web 的跨端编译大多是选择了使用 "桥" 的概念来调用编译产物,通常是使用了原生端的入口 + web 端的桥来实现),Flutter 几乎是把 dart 的源码通过不同平台的编译原理生成各平台的产物,这种"去桥"的产物正式我们所希望得到的、贴近原生运行性能的编译产物(当然,在 dart 最初设计的时候,是参考了很多前端的结构来完成的,特别从语法上面能够很明显地感受到前端的痕迹,而且最初的 dart2js 的原理也是同样"桥"的概念)。

例如 9月23号 google 发布的新 Flutter 版本中,在支持的 Windows 编译产物上,就是通过类似 Visual Studio 的编译工具(如果要将你的 Flutter 工程编译成 Windows 产物,需要提前安装一些 VS 相关的编译插件),生成了 Windows 下的工程解决方案 .sln,最终生成 dll 的调用方式,运行起来很流畅,可以下载附件中的 Release.zip 来尝试运行。(Release.zip 下载

(PS:这里所有编译工程都是通过同一套代码完成,包括上文中的 web 地址、移动端案例还有这里的 Windows 案例)

1.4 与 RN 的性能对比

以上是同样功能模块下,Flutter 和 RN 的一些数据上的对比,是从众多的数据中抽取出来比较有代表性的一组。

1.5 跨端平台的多样性

1.6 引擎

Flare-Flutter 是一款十分优秀的 Flutter 动画引擎,编译出的动画已经在 Windows、移动端、web 上亲测验证过。

1.7 语法糖

A?.B
如果 A 等于 null,那么 A?.B 为 null
如果 A 不等于 null,那么 A?.B 等价于 A.B
Animal animal = new Animal('cat');
Animal empty = null;
//animal 非空,返回 animal.name 的值 cat
print(animal?.name);
//empty 为空,返回 null
print(empty?.name);
A??B
如果 A 等于 null,那么 A??B 为 B
如果 A 不等于 null,那么 A??B 为 A

1.8 综合测评

1.9 互动应用

Flutter 生成的互动可以嵌入到任何端中使用精简的指令集进行互动,为互动场景(教学场景等带来巨大的希望),以下是直播同步互动的 demo 场景。

image

二、Flutter 业务架构

Flutter 中目前是没有现成的 mvvm 框架的,但是我们可以利用 Element 树特性来实现 mvvm。

2.1 ViewModel

abstract class BaseViewModel {
  bool _isFirst = true;
  BuildContext context;

  bool get isFirst => _isFirst;

  @mustCallSuper
  void init(BuildContext context) {
    this.context = context;
    if (_isFirst) {
      _isFirst = false;
      doInit(context);
    }
  }

  // the default load data method
  @protected
  Future refreshData(BuildContext context);

  @protected
  void doInit(BuildContext context);

  void dispose();
  
class ViewModelProvider<T extends BaseViewModel> extends StatefulWidget {
  final T viewModel;
  final Widget child;

  ViewModelProvider({
    @required this.viewModel,
    @required this.child,
  });

  static T of<T extends BaseViewModel>(BuildContext context) {
    final type = _typeOf<_ViewModelProviderInherited<T>>();
    _ViewModelProviderInherited<T> provider =
        // 查询Element树中缓存的InheritedElement
        context.ancestorInheritedElementForWidgetOfExactType(type)?.widget;
    return provider?.viewModel;
  }

  static Type _typeOf<T>() => T;

  @override
  _ViewModelProviderState<T> createState() => _ViewModelProviderState<T>();
}

class _ViewModelProviderState<T extends BaseViewModel>
    extends State<ViewModelProvider<T>> {
  @override
  Widget build(BuildContext context) {
    return _ViewModelProviderInherited<T>(
      child: widget.child,
      viewModel: widget.viewModel,
    );
  }

  @override
  void dispose() {
    widget.viewModel.dispose();
    super.dispose();
  }
}

// InheritedWidget可以被Element树缓存
class _ViewModelProviderInherited<T extends BaseViewModel>
    extends InheritedWidget {
  final T viewModel;

  _ViewModelProviderInherited({
    Key key,
    @required this.viewModel,
    @required Widget child,
  }) : super(key: key, child: child);

  @override
  bool updateShouldNotify(InheritedWidget oldWidget) => false;

2.2 DataModel

import 'dart:convert';

import 'package:pupilmath/datamodel/base_network_response.dart';
import 'package:pupilmath/datamodel/challenge/challenge_ranking_list_item_data.dart';
import 'package:pupilmath/utils/text_utils.dart';

///历史榜单
class ChallengeHistoryRankingListResponse
    extends BaseNetworkResponse<ChallengeHistoryRankingData> {
  ChallengeHistoryRankingListResponse.fromJson(Map<String, dynamic> json)
      : super.fromJson(json);

  @override
  ChallengeHistoryRankingData decodeData(jsonData) {
    if (jsonData is Map) {
      return ChallengeHistoryRankingData.fromJson(jsonData);
    }
    return null;
  }
}

class ChallengeHistoryRankingData {
  String props;
  int bestRank; //最佳排名
  int onlistTimes; //上榜次数
  int total; //总共挑战数
  List<ChallengeHistoryRankingItemData> ranks; //先给10天

  //二维码
  String get qrcode =>
      TextUtils.isEmpty(props) ? '' : json.decode(props)['qrcode'] ?? '';

  ChallengeHistoryRankingData.fromJson(Map<String, dynamic> json) {
    props = json['props'];
    bestRank = json['bestRank'];
    onlistTimes = json['onlistTimes'];
    total = json['total'];
    if (json['ranks'] is List) {
      ranks = [];
      (json['ranks'] as List).forEach(
          (v) => ranks.add(ChallengeHistoryRankingItemData.fromJson(v)));
    }
  }
}

///历史战绩的item
class ChallengeHistoryRankingItemData {
  ChallengeRankingListItemData champion; //当天最好成绩
  ChallengeRankingListItemData user;

  ChallengeHistoryRankingItemData.fromJson(Map<String, dynamic> json) {
    if (json['champion'] is Map)
      champion = ChallengeRankingListItemData.fromJson(json['champion']);
    if (json['user'] is Map)
      user = ChallengeRankingListItemData.fromJson(json['user']);
  }

2.3 View

import 'dart:convert';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:pupilmath/datamodel/challenge/challenge_history_ranking_list_data.dart';
import 'package:pupilmath/entity_factory.dart';
import 'package:pupilmath/network/constant.dart';
import 'package:pupilmath/network/network.dart';
import 'package:pupilmath/utils/print_helper.dart';
import 'package:pupilmath/viewmodel/base/abstract_base_viewmodel.dart';
import 'package:rxdart/rxdart.dart';

//每日挑战历史战绩
class ChallengeHistoryListViewModel extends BaseViewModel {
  BehaviorSubject<ChallengeHistoryRankingData> _challengeObservable =
      BehaviorSubject();

  Stream<ChallengeHistoryRankingData> get challengeRankingListStream =>
      _challengeObservable.stream;

  @override
  void dispose() {
    _challengeObservable.close();
  }

  @override
  void doInit(BuildContext context) {
    refreshData(context);
  }

  @override
  Future refreshData(BuildContext context) {
    return _loadHistoryListData();
  }

  _loadHistoryListData() async {
    Map<String, dynamic> parametersMap = {};
    parametersMap["pageNum"] = 1;
    parametersMap["pageSize"] = 10; //拿10天数据

    handleDioRequest(
      () => NetWorkHelper.instance
          .getDio()
          .get(challengeHistoryListUrl, queryParameters: parametersMap),
      onResponse: (Response response) {
        ChallengeHistoryRankingListResponse rankingListResponse =
            EntityFactory.generateOBJ(json.decode(response.toString()));

        if (rankingListResponse.isSuccessful) {
          _challengeObservable.add(rankingListResponse.data);
        } else {
          _challengeObservable.addError(null);
        }
      },
      onError: (error) => _challengeObservable.addError(error),
    );
  }

  Future<ChallengeHistoryRankingData> syncLoadHistoryListData(
    int pageNum,
    int pageSize,
  ) async {
    Map<String, dynamic> parametersMap = {};
    parametersMap["pageNum"] = pageNum;
    parametersMap["pageSize"] = pageSize;

    try {
      Response response = await NetWorkHelper.instance
          .getDio()
          .get(challengeHistoryListUrl, queryParameters: parametersMap);
      ChallengeHistoryRankingListResponse rankingListResponse =
          EntityFactory.generateOBJ(json.decode(response.toString()));
      if (rankingListResponse.isSuccessful) {
        return rankingListResponse.data;
      } else {
        return null;
      }
    } catch (e) {
      printHelper(e);
    }
    return null;
  }

2.4 一些基础架构

2.5 View 和 ViewModel 如何实现初始化和相互作用

2.6 Flutter 业务架构抽离

如果是统一系列的产品业务形态,还可以抽离出一套核心的架构,复用在同样的生产产品线上,例如当前产品线以教育为主,利用 Flutter 的一码多端性质,则可以把题版生产工厂、渲染题版引擎、 适配框架、 以及跨端接口的框架都抽离出来,迅速地形成可以推广复用的模板,可以事半功倍地解决掉业务上的试错成本问题,当然,其他产品性质的业务线均可如此。

三、Flutter 适配

任何框架中的 UI 适配都是特别繁重的工作,跨端上的适配更是如此,因此在同一套布局里面,各个平台的换算过程显得尤为重要,起初的时候,Flutter 中并没有提供某种诸如 dp 或者 sp 的适配方式,而且考虑到直接更改底层 Matrix 换算比例的话可能会让原本高清分辨率的手机显示不是那么清楚,而 Flutter 的宽高单位都是 num,最后编译的时候才会去对应到各个平台的单位尺寸。

为了减轻设计师的设计负担,这里通常使用一套 iOS 的设计稿即可,以375 x 667的通用设计稿为例,转换过来到android上是360 x 640 (对应1080 x 1920),这里flutter的单位也是和对应手机的像素密度有关的。

3.1 构造一个转换工具类:

//目前适配iPhone和iPad机型尺寸
import 'dart:io';
import 'dart:ui';
import 'dart:math';

import 'package:pupilmath/utils/print_helper.dart';

bool initScale = false;
//针对iOS平台的scale系数
double iosScaleRatio = 0;
//针对android平台的scale系数
// (因为所有设计稿均使用iOS的设计稿进行,所以需要转换为android设计稿上的尺寸,
// 否则无法进行小屏幕上的适配)
double androidScaleRatio = 0;
//文字缩放比
double textScaleRatio = 0;

const double baseIosWidth = 375;
const double baseIosHeight = 667;
const double baseIosHeightX = 812;

const double baseAndroidWidth = 360;
const double baseAndroidHeight = 640;

void _calResizeRatio() {
  if (Platform.isIOS) {
    final width = window.physicalSize.width;
    final height = window.physicalSize.height;
    final ratio = window.devicePixelRatio;
    final widthScale = (width / ratio) / baseIosWidth;
    final heightScale = (height / ratio) / baseIosHeight;
    iosScaleRatio = min(widthScale, heightScale);
  } else if (Platform.isAndroid) {
    double widthScale = (baseAndroidWidth / baseIosWidth);
    double heightScale = (baseAndroidHeight / baseIosHeight);
    double scaleRatio = min(widthScale, heightScale);
    //取两位小数
    androidScaleRatio = double.parse(scaleRatio.toString().substring(0, 4));
  }
}

bool isFullScreen() {
  return false;
}

//缩放
double resizeUtil(double value) {
  if (!initScale) {
    _calResizeRatio();
    initScale = true;
  }

  if (Platform.isIOS) {
    return value * iosScaleRatio;
  } else if (Platform.isAndroid) {
    return value * androidScaleRatio;
  } else {
    return value;
  }
}

//缩放还原
//每个屏幕的缩放比不一样,如果在iOS设备上出题,则题目坐标值需要换算成原始坐标,加载的时候再通过不同平台换算回来
double unResizeUtil(double value) {
  if (iosScaleRatio == 0) {
    _calResizeRatio();
  }

  if (Platform.isIOS) {
    return value / iosScaleRatio;
  } else {
    return value / androidScaleRatio;
  }
}

//文字缩放大小
_calResizeTextRatio() {
  final width = window.physicalSize.width;
  final height = window.physicalSize.height;
  final ratio = window.devicePixelRatio;
  double heightRatio = (height / ratio) / baseIosHeight / window.textScaleFactor;
  double widthRatio = (width / ratio) / baseIosWidth / window.textScaleFactor;
  textScaleRatio = min(heightRatio, widthRatio);
}

double resizeTextSize(double value) {
  if (textScaleRatio == 0) {
    _calResizeTextRatio();
  }
  return value * textScaleRatio;
}

double resizePadTextSize(double value) {
  if (Platform.isIOS) {
    final width = window.physicalSize.width;
    final ratio = window.devicePixelRatio;
    final realWidth = width / ratio;
    if (realWidth > 450) {
      return value * 1.5;
    } else {
      return value;
    }
  } else {
    return value;
  }
}

double autoSize(double percent, bool isHeight) {
  final width = window.physicalSize.width;
  final height = window.physicalSize.height;
  final ratio = window.devicePixelRatio;
  if (isHeight) {
    return height / ratio * percent;
  } else {
    return width / ratio * percent;
  }

3.2 具体使用:

这样每次如果有分辨率变动或者适配方案变动的时候,直接修改 resizeUtil 即可,但是这样带来的问题就是,在编写过程中单位变得很冗长,而且不熟悉团队工程的人会容易忘写,导致查错时间变长,代码侵入性较高,于是利用 dart 语言的扩展函数特性,为 resizeUtil 做一些改进。

3.3 低侵入式的 resizeUtil

通过扩展 dart 的 num 来构造想要的单位,这里用 dp 和 sp 来举例,在 resizeUtil 中加入扩展:

extension dimensionsNum on num {
  ///转为dp
  double get dp => resizeUtil(this.toDouble());

  ///转为文本大小sp
  double get sp => resizeTextSize(this.toDouble());

  ///转为pad文字适配
  double get padSp => resizePadTextSize(this.toDouble());

然后在布局中直接书写单位即可:

四、Flutter 中的一些坑

4.1 泛型上的坑

刚开始在移动端上使用泛型来做数据的自动解析时,使用了 T.toString 来判断类型,但是当编译成 web 的 release 版本时,在移动端正常运行的程序在web上无法正常工作:

刚开始的时候把目标一直定位在编译的方式上,因为存在 dev profile release 三种编译模式,只有在 release 上无法运行,误以为是 release 下编译有 bug,随着和 Flutter 团队的深入讨论后,发现其实是泛型在 release 模式下的坑,即在 web 版本的 release 模式下,一切都会进行压缩(包含类型的定义),所以在 release 下,T.toString() 返回的是 null,因此无法识别出泛型特征,具体的讨论链接:

Flutter application which use canvas to build self-CustomPainter cannot work on browser if i used the release mode by command "flutter run -d chrome --release" or "flutter build web". · Issue #47967 · flutter/flutter​github.com

In release mode everything is minified, the (T.toString() == "Construction2DEntity") comparison fails and you get entity null returned.
If you change the code to (T ==Construction2DEntity) it will fix your app.

最后建议,无论在何种模式下,都直接写成T==的形式最为安全。

class EntityFactory {
  static T generateOBJ<T>(json) {
    if (1 == 0) {
      return null;
    } else if (T == "ChallengeRankingListDataEntity") {
      /// 每日挑战排行榜
      return ChallengeHomeRankingListResponse.fromJson(json) as T;
    } else if (T == "KnowledgeEntity") {
      return KnowledgeEntity.fromJson(json) as T;
    }
  }

4.2 在编译成 web 产物后如何使用 iframe 来加载其他网页

对于移动端来说,webview_flutter 可以解决掉加载 web 的问题,不过编译成 web 产物后,已经无法直接使用 WebView 插件来进行加载,此时需要用到 dart 最初设计来编写网页的一些方式,即 HtmlElmentView:

import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'dart:html' as html;

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
           child: Iframe()
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: (){},
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), 
    );
  }
}
class Iframe extends StatelessWidget {
  Iframe(){
    ui.platformViewRegistry.registerViewFactory('iframe', (int viewId) {
      var iframe = html.IFrameElement();
      iframe.data-original='https://flutter.dev';
      return iframe;
  });
  }
  @override
  Widget build(BuildContext context) {
    return Container(
      width:400,
      height:300,
      child:HtmlElementView(viewType: 'iframe')
    );
  }

不过这种方式会带来新的底层刷新渲染问题(当鼠标移动到某个元素时,会不停地闪动刷新),目前在新的版本上已修复,有兴趣的同学可以看看:

https://github.com/flutter/fl...

4.3 Flutter 如何加载本地的 html 并且进行通信

内置 html 是很多工程的需求,很多网上的资料都是通过把本地的 html 做成数据流的方式然后加载进来,这种做法的兼容性很不好,而且编写过程中容易出现很多文件流过大无法读取的问题,其实这些做法都不是很舒适,我们应该通过 IFrameElement 来进行加载并通信,做法和前端很类似:

4.4 在 iOS 13.4 上 WebView 的手势无法正常使用

官方的 webview_flutter 在上一个版本当 iOS 升级到13.4之后会出现手势被拦截且无法正常使用的情况,换成flutter_webview_plugin后暂时解决掉该问题(目前 WebView 已经做了针对性的修复,但是还未验证),但是 flutter_webview_plugin 在 iOS 上又无法写入 user-agent,目前可以通过修改本地的插件代码进行解决:

文件位置为:

flutter/.pub-cache/hosted/pub.flutter-io.cn/flutter_webview_plugin-0.3.11/ios/Classes/FlutterWebviewPlugin.m修改内容为在146行(initWebview方法中初始化WKWebViewConfiguration后)添加如下代码if (@available(iOS 9.0, *)) {if (userAgent != (id)[NSNull null]) {self.webview.customUserAgent = userAgent;}}

关于 webview_flutter 的手势问题还在不断的讨论中:

https://github.com/flutter/fl...

五、关于布局和运算

5.1 容器 Widget 和渲染 Widget

5.2 GlobalKey

通过 GlobalKey 获取 RenderBox 来获取渲染出的控件的 size 和 position 等参数:

5.3 浮点运算

在 dart 的浮点运算中,由于都是高精度的 double 运算,当运算长度过长的时候,dart 会自动随机最后的一位小数,这样会导致每一次有些浮点运算每一次都是不确定的,这时需要手动进行精度转换,例如在计算两条线段是否共线时:

5.4 Matrix 的平移和旋转

在矩阵的换算过程中,如果使用普通的matrix.translate,会导致 rotate 之后,再进行 translate 会在旋转的基数上面做系数叠加平移运算,这样计算后得到的不是自己想要的结果,因此如果运算当中有 rotate 操作时,应当使用 leftTranslate 来保证每次运算的独立性:

六、项目优化

6.1 避免 build() 方法耗时:

6.2 重绘区域优化:

6.3 尽量避免使用 Opacity

6.4 Flutter的单线程模型

优先全部执行完 Microtask Queue 中的 Event,直到 Microtask Queue 为空,才会执行 Event Queue 中的 Event。

6.5 耗时方法放在 Isolate

Isolate 是 Dart 里的线程,每个 Isolate 之间不共享内存,通过消息通信。

Dart 的代码运行在 Isolate 中,处于同一个 Isolate 的代码才能相互访问。

七、杂谈总结

经历了对 Flutter 长期的探索和项目验证,目前对 Flutter 有自己的一些杂谈总结:

7.1

Flutter 在移动端的表现还是很不错的,在运行流畅度方面也是非常棒,经过优化过后的带大量图像运算的 app 运行在2013年的旧 Android 手机上面依然十分流畅,iOS 的流畅程度也堪比原生。

7.2

对于 web 的应用来说,Flutter 还在不断地改进,其中还有很多的坑没有解决,这里包括了移动端的 WebView 以及编程成的 web 应用,还不适合大面积的投入到 web 的生产环境中。

7.3

关于和 Native 的混编,为了避免产生混合栈应用中的内存问题和渲染问题等,建议尽量将嵌入原生的 Flutter 节点设计在叶子节点上,即业务栈跳转到 Flutter 后尽量完成结束后再回到Native栈中。

7.4

基于“去桥”的原生编译方式,Flutter 在未来各个平台上的运行应该会充满期待,目前验证的移动端应用打包成 Windows 应用后,运行表现还是很不错的,当然一些更大型的应用需要时间去摸索和完善。

7.5

语法方面,Flutter 中的 dart 正在变得越来越简单,也在借鉴一些优秀的前端框架上的语法,例如 react 等,kotlin 中也有很多相似的地方,感觉 Flutter 团队正在努力地促进大前端时代的发展。


总之,Flutter 确实带来了很多以前的跨端方案没法满足的惊喜的地方,相信不久的将来一码多端会变得越来越重要,特别是在新业务的探索成本上表现得十分抢眼。

以上是一些对 Flutter 的一些粗浅的总结,欢迎有兴趣的小伙伴一起探讨。

网易有道,与你同道,因为热爱所以选择, 期待志同道合的你加入我们。
  • END -
查看原文

赞 9 收藏 5 评论 0

有道技术团队 发布了文章 · 2020-12-31

有道精品课实时数据中台建设实践

撰文/ 李荣谦

编辑/ Ryan

来源:有道技术团队(ID: youdaotech)

0 序言

本期文章中,有道精品课技术团队将和大家分享有道精品课数据中台的架构演进过程以及 Doris 作为一个 MPP 分析型数据库是如何为不断增长的业务体量提供有效支撑并进行数据赋能的。

本文以我们在实时数仓选型的经验为切入点,进一步着重分享使用 Doris 过程中遇到的问题,以及我们针对这些问题所做出的调整和优化。

1 背景概述

1.1 业务场景

根据业务需求,目前有道精品课的数据层架构上可分为离线实时两部分。

离线系统主要处理埋点相关数据,采用批处理的方式定时计算。而实时流数据主要来源于各个业务系统实时产生的数据流以及数据库的变更日志,需要考虑数据的准确性、实时性和时序特征,处理过程非常复杂。

有道精品课数据中台团队依托于其实时计算能力在整个数据架构中主要承担了实时数据处理的角色,同时为下游离线数仓提供实时数据同步服务。

数据中台主要服务的用户角色和对应的数据需求如下:

  1. 运营/策略/负责人主要查看学生的整体情况,查询数据中台的一些课程维度实时聚合数据;
  2. 辅导/销售主要关注所服务学生的各种实时明细数据;
  3. 品控主要查看课程/老师/辅导各维度整体数据,通过T+1的离线报表进行查看;
  4. 数据分析师对数据中台 T+1 同步到离线数仓的数据进行交互式分析;

1.2 数据中台前期系统架构及业务痛点

如上图所示,在数据中台1.0架构中我们的实时数据存储主要依托于 Elasticsearch,遇到了以下几个问题:

  1. 聚合查询效率不高
  2. 数据压缩空间低
  3. 不支持多索引的 join,在业务设计上我们只能设置很多大宽表来解决问题
  4. 不支持标准 SQL,查询成本较高

2、实时数仓选型

基于上面的业务痛点,我们开始对实时数仓进行调研,调研了 Doris、ClickHouse、TiDB+TiFlash、Druid、Kylin,考虑到查询性能、社区发展、运维成本等多种因素,我们最后选择 Doris 作为我们的实时数仓。

3、基于Apache Doris的数据中台2.0

3.1 架构升级

在完成了实时数仓的选型后,我们针对 Doris 做了一些架构上的改变,以发挥它最大的作用,主要分为以下几个方面:

>>>>Flink双写

将所有 Flink Job 改写,在写入Elasticsearch的时候旁路输出一份数据到 Kafka,并对复杂嵌套数据创建下游任务进行转化发送到 Kafka,Doris 使用 Routine Load 导入数据。

>>>>Doris On Es

由于之前我们的实时数仓只有 Es,所以在使用 Doris 的初期,我们选择了通过 Doris 创建 Es 外表的方式来完善我们的 Doris 数仓底表,同时也降低了查询成本,业务方可以无感知的使用数仓底表。

>>>>数据同步

原来我们使用 Es 的时候,由于很多表没有数据写入时间,数据分析师需要每天扫全表导出全量数据到 Hive,这对我们的集群有很大压力,并且也会导致数据延迟上升,我们在引入了 Doris 后,对所有数仓表都添加 eventStamp, updateStamp, deleted 这三个字段。

  • eventStamp:事件发生时间
  • updateStamp:Doris数据更新时间,在Routine Load中生成
  • deleted:数据是否删除,由于我们很多实时数仓需要定时同步到离线数仓,所以数据需要采取软删除的模式。

数据同步我们采用了多种方式,通过 hive 表名后缀来决定不同同步场景:

  • _f:每天/每小时全量同步,基于 Doris Export 全量导出
  • _i:每天/每小时增量同步,基 于Doris Export 按分区导出/网易易数扫表导出
  • _d:每天镜像同步,基于 Doris Export 全量导出

>>>>指标域划分/数据分层

将 Elasticsearch 中的数据进行整理并结合后续的业务场景,我们划分出了如下四个指标域:

根据上面的指标域,我们基于星型模型开始构建实时数仓,在 Doris 中构建了20余张数仓底表以及10余张维表,通过网易易数构建了完整的指标系统。

>>>>定时生成 DWS/ADS 层

基于 Doris insert into select 的导入方式,我们实现了一套定时根据 DWD 层数据生成 DWS/ADS 层数据的逻辑,延迟最低可以支持到分钟级。

>>>数据血缘

我们基于 Routine Load 和 Flink 实现了数据中台完善的数据血缘,供数据开发/数据分析师进行查询。

3.2 数据中台2.0架构

基于围绕 Doris 的系统架构调整,我们完成了数据中台2.0架构:

  • 使用网易易数数据运河替换 Canal,拥有了更完善的数据订阅监控
  • Flink计算层引入 Redis/Tidb 来做临时/持久化缓存
  • 复杂业务逻辑拆分至 Grpc 服务,减轻Flink中的业务逻辑
  • 数据适配层新增 Restful 服务,实现一些 case by case 的复杂指标获取需求
  • 通过网易易数离线调度跑通了实时到离线的数据同步
  • 新增了数据报表/自助分析系统两个数据出口

数据中台2.0架构的数据流转如下图所示:

我们对数据中台整体架构进行梳理,整体结构如下图所示:

4、Doris带来的收益

1. 数据导入方式简单,我们针对不同业务场景使用了三种导入方式:

  • Routine Load:实时异步数据导入
  • Broker Load:定时同步离线数仓数据,用于查询加速
  • Insert into:定时通过 DWD 层数仓表生成 DWS/ADS 层数仓表

2. 数据占用空间降低,由原来Es中的1T左右降低到了200G左右。

3. 数仓使用成本降低:

Doris 支持 Mysql 协议,数据分析师可以直接进行自助取数,一些临时分析需求不需要再将 Elasticsearch 数据同步到 Hive 供分析师进行查询。

一些在 Es 中的明细表我们通过 Doris 外表的方式暴露查询,大大降低了业务方的查询成本。

同时,因为 Doris 支持 Join,原来一些需要查询多个 Index 再从内存中计算的逻辑可以直接下推到 Doris 中,提升了查询服务的稳定性,加快了响应时间。

聚合计算速度通过物化视图和列存优势获得了较大提升。

5、上线表现

目前已经上线了数十个实时数据报表,在线集群的 P99 稳定在 1s 左右。同时也上线了一些长耗时分析型查询,离线集群的 P99 稳定在 1min 左右。

同时,也形成了一套完善的开发体系使数据需求的日常迭代更加迅速。

6、总结规划

Doris 的引入推进了有道精品课数据分层的构建,加速了实时数仓的规范化进程,数据中台团队在此基础上一方面向全平台各业务线提供统一的数据接口,并依托于 Doris 生产实时数据看板,另一方面定时将实时数仓数据同步至下游离线数仓供分析师进行自助分析,为实时和离线场景提供数据支撑。

对于后续工作的开展,我们做了如下规划:

  • 基于Doris明细表生成更多的上层聚合表,降低Doris计算压力,提高查询服务的整体响应时间。
  • 基于Flink实现Doris Connector,实现Flink对Doris的读写功能
  • 开发Doris On Es支持嵌套数据的查询。

最后,感谢各业务方对数据中台的支持,目前数据中台还在迅速发展中,欢迎志同道合的朋友加入我们。

查看原文

赞 1 收藏 0 评论 0

有道技术团队 发布了文章 · 2020-12-25

网易 Duilib:功能全面的开源桌面 UI 开发框架

网易 Duilib 框架概述

Duilib 是 Windows 系统下的开源的 DirectUI 界面库(遵循 BSD 协议),完全免费,可用于商业软件开发。

Duilib 可以简单方便地实现大多数界面需求,包括换肤、换色、透明等功能,支持多种图片格式,使用 XML 可以方便地定制窗口,能较好地做到 UI 和逻辑相分离,尽量减少在代码里创建 UI 控件。目前,Duilib 已经在国内有较为广泛的使用。

网易在研发网易易信 PC 版时引入 Duilib,经过多年开发和改进,由网易云信在2019年4月开源。(github 地址:https://github.com/netease-im...

网易 Duilib 使用 C++11 重写,在其原有基础上做了较大重构,搭配谷歌的基础组件 Base 库、基于 Chromium 的 WebView 框架 CEF 以及常用的 UI 组件,形成了一套功能强大、简单易用的完整桌面 UI 开发框架。

网易 Duilib 整体框架

整体组件架构

组件架构图
框架中提供了多线程模型、高精度定时器、基本的 xml 解析、zip 解压等功能;封装了一层渲染接口和全局样式资源的统一管理;并且对 DPI 适配、多语言、虚拟键盘、手写板等功能增加了支持;在上层提供了丰富的控件。

线程模型和消息队列

开发框架中集成了 Chromium 中 base 库的线程模型和消息队列,base 中包含了多种消息循环、异步操作接口。
base 线程消息循环
网易 Duilib 框架中的 UI 消息循环、工作线程都完全依托 base 的线程模型。使用 base 的异步通信能力,我们可以将耗时的工作(如资源解析)放到辅助线程来减轻 UI 线程的压力。
base 异步通信
同时,网易 Duilib 中的各种基础组件,都已经继承了 base 中的生命周期检测能力,每个任务在执行时都会先检查与之绑定的对象是否存活,确保多线程操作不会因野指针而导致崩溃。实际项目开发中,使用base的线程模型,我们可以非常简单做到 UI 线程、数据库线程、网络线程、其他工作线程之间的通信与交互,有效提升开发速度。

网易 Duilib 的功能特点

更加丰富的功能

网易 Duilib 框架提供了更加完整和丰富的功能,以满足不同真实业务场景的需求:

  • 丰富的控件、简易的布局
  • 灵活的控件组合、事件处理方式
  • 模块化支持
  • 优化渲染效率
  • 异形窗体支持
  • DPI 适配支持
  • 多国语言支持
  • 通用样式支持
  • 虚表控件支持
  • 虚拟键盘支持
  • 实用的多线程支持
  • CEF webview 支持
  • 控件动画、GIF 动画支持
  • 触控设备支持(Surface、Wacom)
  • 抽象渲染接口(为其他渲染引擎提供支持)

灵活的布局与组合

网易 Duilib 中,增加了控件与容器的尺寸自适应功能,免去繁琐的手写尺寸。同时增强了布局能力,搭配控件的一些定位属性,可以使用少量 xml 代码来完成更加强大的布局效果。
新增的绝对布局
​现在的 UI 库中,把布局、容器、控件等逻辑组件拆分开,让不同的布局可以与任意容器进行灵活的组件。并且弱化了容器与控件的区别,基础控件使用模版来编写,上层使用时可以让它继承不同的控件或模版,让控件本身也可以是容器:

typedef LabelTemplate<Control> Label; 
typedef LabelTemplate<Box> LabelBox; 
typedef ButtonTemplate<Control> Button; 
typedef ButtonTemplate<Box> ButtonBox; 
typedef CheckBoxTemplate<Control> CheckBox; 
typedef CheckBoxTemplate<Box> CheckBoxBox;

基础控件继承了容器后,就可以拥有控件本身的行为+容器的组合能力。这样做的优点是如果一个基础组件在 UI 上无法满足需求,那么就让他成为容器去任意组合其他的 UI 组件,提升控件的表现能力。同时控件支持在 xml 中编写简单的事件处理逻辑,把一些功能简单的UI控制逻辑放在 xml 中。

功能强大的 web 展现组件:CEF WebView

CEF(Chromium Embedded Framework)是基于谷歌 Chromium 浏览器内核封装出的跨平台 web browser 组件。

CEF 内部有完整的一套消息循环,我们将网易 Duilib 框架中的 base 线程模型与 CEF 消息循环组合在一起。同时封装CEF的离屏渲染模式(OSR)、窗口模式为网易 Duilib 中的控件 CefControl、CefNativeControl,让 CEF 的 WebView 能力完整的嵌入到网易 Duilib 中。最后我们封装了js与native的通信能力 JsBridge。
CEF执行流程
如此,我们可以使用 CefControl、CefNativeControl 来做单纯的 web 展示控件,也可以以 WebView 为核心,网易 Duilib 为辅助,开发 web app。UI 层的展现都由 web 页面负责,底层的核心逻辑、数据库、网络等由 C++ 负责,web 与 C++ 使用 JsBridge 通信。

应用实例:有道精品课

有道精品课是网易旗下在线教育平台,教师通过在线直播的方式对学生授课,需要一个支持直播、聊天、课件分享、手写板、web、答题互动等功能的客户端让老师使用,因此有道精品课教师端应运而生。

老师可以使用有道精品课教师端进行 PPT、PDF、桌面共享、iPad 投屏、视频播放等多种直播方式 。老师也可以把讲课过程中画板上的板书和课件导出为 PDF 分享给学生。
有道精品课教师端
我们需要让教师端满足各种直播需求的同时,保证开发的速度、易用性、扩展性。另外,由于音视频、聊天等功能需要消耗大量 CPU 资源,这就要求客户端的 UI 本身只能占用较低的内存和 CPU 资源。基于以上需求,我们使用网易 Duilib 框架进行了有道精品课教师端的开发。

教师端的画板拥有丰富的功能:绘制各种图形图片、图形交互、书写文本、PPT解析、动画、导出 PDF、缩放等等。

画板支持的特性越复杂,就越需要消耗更多的 CPU。为了节省 CPU 资源,我们搭配网易 Duilib 框架中的渲染引擎,设计了6层缓存机制,让画板在支持丰富功能的同时保证极低的 CPU 占用率。
画板的多层缓存方案
依托于网易 Duilib 框架的 UI 组件和渲染能力,教师端画板可以支持手写板笔迹、毛笔,支持绘制各种图形图片,支持画板缩放。
毛笔
图形图画

由于老师的课件大多为 PPT 编写,为了让老师授课更加方便,教师端支持导入 PPT。并且使用网易 Duilib 框架的动画能力,来支持展现 PPT 元素的动画,让老师方便播放动画或控制 PPT 元素。
支持PPT动画
​配合网易 Duilib 框架的 WebView 能力,可以把 web 页面融入到画板里,既可以操作网页也可以写书板书,极大增强画板表现力。使用这样的能力,我们可以让教师端支持展现数学互动题、物理实验、化学实验等等内容。
画板内嵌web

不断优化迭代,与开发者同行

目前,我们已经将网易 Duilib 开源,github 地址
https://github.com/netease-im...

欢迎大家使用并与网易 Duilib 同行。

计划在不久之后,我们将支持矢量图来增强 DPI 适配能力,增加更加丰富的动画(帧动画、属性动画、路径动画、特效动画)来提升 UI 库的表现力,并替换性能更好的 skia 渲染引擎。

未来,我们将持续迭代优化网易 Duilib 框架,不断的扩展它的功能。在 github 社区里,已经有很多的开发者参与到项目的迭代中。

欢迎更多开发者朋友的加入。

特别鸣谢:感谢自网易 Duilib 成立以来,为之贡献过以及仍在贡献代码的小伙伴们,包括但不限于 阳光、redrain、harrison 等。

撰文/ Redrain
编辑/ Ryan
来源/ 有道技术团队(ID: youdaotech)

查看原文

赞 7 收藏 2 评论 0

有道技术团队 关注了用户 · 2020-12-09

null仔 @xiaoqianduan_58b28cfebff36

总是有人要赢的,那为什么不能是我呢

关注 3058

有道技术团队 关注了专栏 · 2020-12-09

bigsai

微信搜索一艘:bigsai 欢迎叨扰!

关注 6703

认证与成就

  • 获得 36 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-12-09
个人主页被 1.2k 人浏览