🙋🏻♀️ 编者按:本文为《Cube 技术解读》系列第五篇文章,作者是作者是蚂蚁集团客户端工程师弘祖,重点阐述 Inline Text 技术原理与实现,欢迎查阅~
支付宝客户端有极强的动态化诉求,不论 iOS 还是 Android 平台,重新分发软件包在时间上、效率上都难以满足产品运营的要求,所以客户端动态化技术应运而生。
Cube 起源于 Native 页面的动态化诉求,产品形态表现于 Cube 卡片。随着小程序的出现,Cube 融入了支付宝小程序技术栈,产品形态为轻量级的支付宝小程序解决方案(相对于使用浏览作为核心的 Web 小程序)。作为一个轻量级引擎,Cube 小程序具有体积小、启动快、内存占用低的特点,我们使用自研渲染技术,支持 CSS 子集来实现这些特点。
与浏览器不同,Cube 小程序引擎的输入是一个小程序 DSL(可以理解为小程序规范的语言) 构建后的产物(产物主要由一个 JS 文件以及相关资源组成)输出为用户界面以及后续的交互(不断的用户输入和 UI 输出)。Cube 小程序不断迭代支持样式表,Inline Text 能够做到在较小的包体积(主 so 只有 2.8 MB)的情况下,支持非常多的 CSS 样式,并且布局绘制与 Web 浏览器几乎完全一致。
Inline Text
什么是 Inline Text 呢?这要从布局引擎开始介绍,布局引擎又称排版引擎,是一个软件组件,负责获取标记或者元素结合样式产生相应的位置、大小以及层级等排版结果。这个排版结果可以被用于输出到显示器也可以被输出到打印机。Cube 引擎使用了 2 个布局引擎,卡片使用了 Yoga,用于高性能布局场景,小程序使用了 Flow Layout。其中 Flow Layout 支持 Flow 布局(流式布局),具有代表性的样式为 display:block ,也就是 Web 中 div 等元素的默认布局行为。
布局引擎的一个重要工作就是文字的排版工作,而大多数非 Web 渲染的技术栈(包括老版本 Cube )使用的是平台层的文本布局对象,在布局阶段根据计算好的样式构造平台层文本对象,计算好宽高位置后再传递回布局引擎,完成布局;后续绘制时使用创建好的平台层对象进行绘制。
使用平台层对象在布局阶段会产生一定程度的性能损耗,这种损耗主要包括两部分:一是消耗在操作系统 API 对文字的布局计算消耗;另外一种是布局引擎与平台层交互时产生的性能损耗。这种实现导致文本只能够以一个一个矩形存在,难以灵活地图文混排,也难以将一段文字中的部分文字加颜色和样式,这也是与 Inline 相关的样式的含义,典型与 Inline 相关的样式有:display:inline 、 display:inline-block 、display:inline-flex 、float:left。以下2个图片就是这种能力的典型代表。
除却非常明显的能力以外,符合相关 Web 标准一直是 Cube 小程序的技术目标之一,下面举一个例子来说明。请看如下代码( HTML 代码 小程序可以换为 view 和 image标签)
<div style="font-size: 60px;"><img src="http://xx"/></div>
是一个可替换元素。它的 display 属性的默认值是 inline,但是它的默认分辨率是由被嵌入的图片的原始宽高来确定的,使得它就像 inline-block 一样,它放置的位置是由文本的基线决定的,这个例子中虽然没有任何文字,但是 img 在排版布局的时候是依赖文字的基线,所以 font-size 会影响 img 的位置,又由于 font-size 是继承的,如果是动态下发的元素,不小心在祖先设置了大小不符合预期的字体就会影响布局和绘制的结果。这个小例子说明了文字对于一个引擎在布局时的影响,可见文字对于页面排版布局的影响是多方面的。作为一个布局引擎,Flow Layout 需要完美的复刻这些行为,Cube 面向标准又迈出了一步。
总结以上几点,Cube 团队决定在 Cube 引擎上将文本相关能力增强,其中包括了对文字的宽高的测量与计算,排版和布局,在增强 CSS 能力的同时又可以提升布局性能,这些文字相关的能力(或者叫 Feature )统一被称为 Inline Text ,还原前端开发者在使用 Cube 引擎时对文字等样式的旧有使用习惯,大幅提升开发体验。进而 Cube 小程序能够承载更多的小程序,进而实现更大的业务价值。
实现细节
对于 Inline Text 在 Cube 引擎中的实现分为两部分:一部分是在 Flow Layout 去掉对接文本平台层部分,增强为直接对文字的宽高进行排版测量,我们称为 文本布局 。另外一部分是绘制,本次实现依然使用平台层绘制,只不过使用了更低一级别的 API,减少操作系统的计算,尽可能直接调用绘制 API,我们称为 文本绘制。为了更好的理解这两部分的关系,以及实现细节,我们先了解一下 光栅化和 文本自绘制 。
光栅化
我们都知道像素的概念,计算机世界是离散的,每一条线,每个字绘制出来都需要到一个一个像素点上,这个从矢量图形到像素的过程叫做光栅化,就像栅格一样。可以参考以下图片:
文本自绘制
与文本自绘制相对应的是现有的渲染流程中对于文字的布局以及渲染都使用了平台层(OS)的 API,也就是 Cube 引擎下 platform 模块中的逻辑。布局时创建对应的平台层对象,通过平台层计算出文本矩形的区域然后把结果传递回布局引擎,拿到结果完成布局。后续绘制时,使用创建好的平台层对象进行绘制。
文本自绘制是指通过布局引擎直接测量文本的宽高,在合适的区域进行摆放,从而完成布局;后续的绘制也直接通过字体的信息(一些描述字形的信息)直接进行光栅化。
Cube 在技术迭代的过程中先进行前半部分布局部分,后半部分随着整个引擎的自绘制一起完成。所以现阶段的方式是布局引擎布局完成后,使用平台层 API 进行绘制,过程可以参考以下图片:
文本布局
为了将文字正确的放置到屏幕或者说一个矩形的正确位置上,我们分为两步,第一步先把每一个字符的宽高计算出来,第二步进行布局计算(摆放文字和其他东西例如图片等)。需要注意的是真正的程序在运行的时候有时候会采用更高效的计算方式,未必需要完全测量每一个字符的宽高,例如等宽字体。
1 单个字符宽高计算
常见的 sans-serif,serif,monospace 被称为通用字体族,他们叫通用字体族也就说他们代表了一系列字体族(FontFamily),交由布局引擎去操作系统中寻找合适的字体。字体族被称为字体族说明一个字体有时候会提供多个版本,这是为了满足不同的样式需要,那么选定了一个字体的其中一个版本后我们称之为 Typeface,Typeface 包含了很多文字,其中每一个单个文字(针对同一个Unicode字符)我们称为 Glyph ,每一个 Glyph 中都包含了一个单个文字信息,对于当代 TrueType 矢量字体,单个文字信息又由多条 贝塞尔曲线 组成,我们称之为矢量图形,根据字体的大小可以得到一个明确的大小的文字,再应用上一些样式以后经过光栅化就可以得到单个字符的宽高。
下面是一个对比的小例子,对于同一个字体,字体的作者会一般来讲会提供两种一种是正常宽度的字体,另外一种是加粗的字体,这个加粗被称为 font-weight ,那一个加粗一个不加粗就代表了两种 Typeface ,根据之前的介绍那么就会有两套 贝塞尔曲线 来描述同一个 Unicode 字符,以下是不加粗和加粗字符Y的曲线。可以想像出来如果字体的作者针对倾斜(italic)单独提供了一个 Typeface 也会像加粗一样拥有单独的曲线描述。
下面介绍一下 TextStyle 。根据上文描述,一个字符最后确定大小除了 Typeface 以外还需要加上一些文本的样式最后才能确定字符宽高,这些样式我们统称为 TextStyle 。了解了 Typeface 等概念以后,我们就会发现绘制一个字体除了 Typeface、文字大小还需要其他参数,比如:color,font-size,是否需要应用 FakeItalic,是否需要 FakeBold(稍后解释),等很多参数。这些除去了 Typeface 以外的信息被抽象为 TextStyle。在确定了 Typeface 以后还需要 TextStyle 信息再经过光栅化后才能够知道一个字形最终渲染的宽高(由于矢量 Typeface 的特点,取宽高未必一定需要光栅化,经过特定的比例计算亦可)。
根据之前的描述,假如字体的作者没有为字体设计 font-weight 对应的 Typeface,也没有为 italic 设计Typeface,那么在用户指定相关样式的时候应该怎么办,这时候 Fake 相关变换就上场了,他可以直接变换原有的字体的曲线来达到看起来变粗和变倾斜的了效果,这时候 FakeItalic 以及 FakeBold 就会应用到字体上,通过提前预制的变换函数,此时 TextStyle 就包含了 FakeItalic 和 FakeBold,反之则不包含。有时候是否应用 Fake 相关变换由布局引擎自行决定。
为了计算文字相关能力我们引入了一些 Library,主要为 Skia 和 Harfbuzz,其中 Skia 裁剪掉了所有绘制部分只保留针对文本的相关抽象,而 Harfbuzz 用于最终测量文本的宽高的库,Harfbuzz 使用 Skia 提供的关于 Typeface 以及 Glyph 的接口。以下简单介绍以下 SkTypeface 对于 Typeface 的抽象,以及字体在各个平台初始化的细节。
在 Skia 库中,SkTypeface 抽象了 Typeface 下面的细节实现库 FreeType (Android), CoreText (iOS),提供了最基础的功能,比如把 Unicode 转换为字体中具体字形的 index(针对 Glyph 的抽象),比如:直接通过 index 取或者计算单个文字轮廓的抽象,在 Android 通过 FreeType 库读取系统的字体文件,在 IOS 上通过 CoreTextAPI 读取相关 table 数据(Typeface 元数据)直接构造。
字体初始化细节同浏览器实现逻辑一致,在 Android 上使用/etc/fonts.xml中的字体信息,老版本的Android 系统使用/system/etc/fonts.xml等类似的4个位置。其中定义了操作系统提供的字体,以及对应的 font-family、font-weight、font-style, language 初始化后准备好后续布局时候找到合适的 Typeface。IOS 上直接使用 CoreText API 获取相关字体列表以及信息。
至此我们就拥有了测量单个文字的能力,通过系统以及用户输入找到对应的 Typeface 配合 TextStyle ,最后使用 Harfbuzz 将文字宽高计算出来。(这里是为了方便理解,实际上可以一次测量多个文字的宽高)
2 布局计算
首先是布局树和样式表,这里不过多介绍,通过 CSS 样式加上相关 Element,构建出布局树,在进行布局之前每一个元素都通过样式表计算有了自己的 ComputedStyle,block 元素,flex 元素按照其默认行为开始布局。inline 元素在自己的 LayoutBlockFlow 中开始布局,根据 ComputedStyle 中的 font-family 确定一个 FallbackList,然后根据字体以及文本以及 white-space 等相关信息边测量文本的宽度,此处引入了 ICU ,这个库用于分割不同的语言,以及确定是否需要断句,标点符号是否放到下一行或者留在上一行,然后根据 Unicode 字符所在的区间,最后确定出 TextRun,最后组成一个一个 LineBox,用于后续绘制使用。此处描述较为精简,后续会有单独一篇文章展开讲布局过程。
文本绘制
1 对接平台层绘制
由于 Inline Text 的特性,必须进行绘制链路的改造。整个渲染链路不过多介绍(请参考其他相关文章),Cube 绘制流程主要的结构是渲染树(内部称为 RenderTree ),这棵树有很多节点,用于描述父子关系,其中文本节点在现阶段属于叶子节点, 在递归进行绘制的时候,原有由于文字是一个一个矩形,所以整个 Text 是使用同一个 border 绘制流程以及背景绘制流程,由于 inline 特点,绘制到文本节点时,对于背景以及 border 的绘制流程需要根据每行来进行绘制,增加了一层循环,对于背景以及 border 折行等有诸多细节要处理,此处要对齐 Web 的绘制效果。后续如果要支持更复杂的文字特性,让文本节点变为非叶子节点,还需要进一步增强绘制流程。
文本经过上一阶段布局计算以后,通过 LineBox 上的信息针对每一个 Text 节点(span)产生了三个数据结构用于绘制:
class TextStyle { int textColor; float textSize; int fontWeight; int textDecoration; int fontStyle; TextShadow textShadow; // 用于描述TextShadow相关属性 float alpha; Padding padding; // 包含left top right bottom } style; class TextRun { int typefaceId; int start; int end; float width; }; class TextLine { String text; float originX; float originY; float ascent; TextRun runs[]; } lines;
展示以上伪代码是为了更好的理解对接平台层绘制的细节,TextStyle 就是之前介绍过的除 Typeface 以外绘制需要用的其他信息,由于 Element 的特点,同一个 Element 下的样式是一致的,所以一个 Text 节点一份 TextStyle,然后是一堆 TextLineInfo,每一个代表一行的数据,每一行里面有好多个 TextRun,代表着每段对应的 Typeface 以及子串的始末,在布局的时候已经提前进行平台侧 Typeface 的创建,等到绘制阶段直接通过 typefaceId 拿到对应的对象绘制即可。其中 Android 平台在布局的时候直接使用 Android 的 API android.graphics.Typeface 创建,iOS 平台由于 SkTypeface 是直接针对 CoreText 对象进行抽象,所以绘制的时候直接使用包裹的 CoreText 相关字体对象进行绘制即可。
包体积
根据 Cube 引擎自身的定位以及小程序的对于文本渲染能力的诉求,Inline Text 对 Skia、Harfbuzz、ICU、Freetype 等进行了深度优化和定制。Cube 在包体积上面做了大量的工作,针对引入的库均做了大量的裁剪,例如 Skia 的绘制部分,去掉一些 Harfbuzz 中不必要的逻辑,针对 ICU 还定制实现了自己的部分,Inline Text 的实现(包含所有依赖库)最终将 Cube 包体积的增加控制在 170kb 左右。
体验与应用
丰富的 CSS 样式与能力
在 Cube 引擎支持 Inline Text 以后,样式 float、display:inline-block 以及 flex 布局中多个元素的基线对齐等细节都得到了完善,做到几乎和浏览器引擎布局结果完全一致。
以下是一些 Inline Text 能力表现,布局引擎具有递归以及套娃特点,例子中也有体现:
- 大段文本中的部分文本使用不一样的样式:
- float:left
- float:left “套娃”
- CJK 文本 配合 word-break:keep-all, 无数个风暴折行不会断开:
- font-family Fallback 机制
当我们在 CSS 中写如下代码的时候意味着:
<style> div { font-family: alipay-number; serif; } </style> <body> <div>123中国456</div> </body>
其中123,456使用 alipay-number, 但是中国使用系统的 serif 字体。原因是 alipay-number 字体没有提供中国这两个 Unicode 的 Glyph。
这个特性好多非 Web 的渲染引擎支持得都不完善,它涉及到 Typeface 的选择规则以及绘制,Inline Text 完美复刻了 Web 渲染引擎的 Fallback 机制。
- font-face 的支持
第三方字体是常见需求,很多业务都无法满足于系统字体,一般来讲可以内置在包里,或者是通过 URL 获取。浏览器的 CSS 在第三方字体准备好以后,通过 CSS 选择器重新触发匹配规则通过 FontSelector 重新选择对应的 Typeface,完成重绘。
与浏览器不同,Cube 引擎的样式匹配非常精简,在第三方字体下载以后,清理掉之前的对应的 font-face的文本缓存,重新触发布局绘制达到同样的效果。又由于样式表的加持,以及 Inline Text 对于 ICU 的接入,font-icon 可以使用伪元素(content)加上私有 Unicode 的方式直接使用,和 Web 体验完全一致。
性能提升
文本布局的性能提升是由于使用了文本测量由布局引擎进行排版以后,以前的与平台层的 JNI 交互没有了,平台层对象的创建与布局逻辑也没有了,文本布局的性能有了大幅提升:
应用场景
目前在优酷 OTT 上 90% 由搭建平台产生的产物都默认开启了 Inline Text,使用了相关能力,提升布局的性能,由于协议页面的需求,开发者无需再使用 Javascript 进行分词更换颜色,直接使用引擎能力,可以参考以下页面,此页面应用 Inline Text 后页面加载时间仅为原来的 1/3:
未来与展望
当前 Inline Text 是 1.0 版本实现,2.0 版本的规划如下:
- 根据目前 Cube 绘制模型,增强支持 textNest(可以理解为 span 标签的嵌套);
- 采用自绘制后端(比如:Skia),直接通过字体信息光栅化达到全平台一致性;
- 支持竖向文本布局;
- 支持阿拉伯等双向文本;
- 支持更多的富文本特性;
- 进一步优化超长文本布局计算耗时。
附录:Inline Text 支持的样式
图例:支持 部分支持 不支持