MiniTex 是一个文本渲染器,同时具备文本排版能力,借助 MiniTex,Flutter Web CanvasKit 应用可以在不加载字体文件的情况下,依然可以渲染多国语言。
MiniTex 支持在 MPFlutter 和 Flutter Web 中使用,开源,免费。
本文主要介绍 MiniTex 的实现原理以及使用方法。
一、背景
Flutter Web 存在 CanvasKit 渲染器,该渲染器使用 Skia WebAssembly 驱动,在该模式下渲染文本需要通过加载 ttf 字体文件实现,这种方式对于 Latin 系语言相对友好,但对于汉语、日语等方块字语言则不太友好。
原因在于:
- 方块字语言字符集较大,故未经裁剪的简体中文字体文件一般都超过 5M;
- 字体版权不容易处理,可商用字体选择范围少,一般是使用 Noto 系列字体;
- Emoji 文本无法渲染,或渲染效果无法对齐原生。
MPFlutter 也同样遇到上述问题,我们是通过裁剪 Noto Sans SC 并内置在应用包中,以驱动中文文本渲染的,这也导致 MPFlutter 初始包大小超过 5M。
我们需要另辟蹊径解决这些问题。
二、技术目标
使用 JavaScript + Web Canvas 2D 编写一个在功能上对齐 Skia 文本渲染能力的库。
该库需要是:
- 跨平台的,至少是微信小程序、Web 都可用的;
- 在接口上对齐 Skia,以保证 Flutter / Dart 的调用是一致的;
- 不能依赖外部的 TTF 字体文件,且需要保证文本换行、描边、阴影、装饰等功能与 Skia 实现一致。
三、原理分析
Skia 是一个开源的 2D 渲染库,主要由矢量绘制、文本绘制、位图绘制功能组合而成。
通过阅读 Flutter 和 Skia 源码,我们梳理出相关的渲染流程,如下图:
流程并不复杂,实际复杂的部分在于 Layout 和 Draw。
复杂的 Layout 处理逻辑可以服务于多变的国际化语言需求,例如 RTL、堆叠文本、分词、断句等能力。
复杂的 Draw 逻辑主要是性能考虑以及多样化的 Decoration 需求。
我们从上述流程也可以得知,如果没有加载合适的字体,Skia 是无法进行 Layout 和 Draw 的。
故,MiniTex 的做法是直接将 Layout 和 Draw 使用 JavaScript 重写。
四、MiniTex 的技术方案
Hook
Skia CanvasKit 的产物有两部分,wasm 和 JavaScript,我们可以很轻松地 Hook JavaScript 接口,为了将 MiniTex 的实现注入到 CanvasKit,可以这样做:
- 替换 SkParagraphBuilder.MakeFromFontCollection 方法,在符合条件的情况下返回 MiniTex 实现的 SkParagraph;
- 替换 SkCanvas.drawParagraph 方法,当遇到 MiniTex 的 SkParagraph 实例时,使用 Web Canvas 2D 绘制文本;
- 将绘制好文本的 Web Canvas 2D,使用 getImageData 获得位图对象,由 Skia 进行常规的位图绘制。
ParagraphBuilder
ParagraphBuilder 的实现非常简单,它接受来自 Client 的 TextStyle 和 Text,并组合成多个 Span。
在 build 的时候,将 spans 和 paragraphStyle 设值给 SkParagraph 即可。
特别要注意的是,Client 的 TextStyle 是可以多层嵌套的,故 Builder 提供的是 pushStyle 和 pop 方法。
Paragraph
Paragraph 是一个实体(数据模型)类,它只负责向具体的 Impl 发送指令,并返回对应的结果。
具体来说,Paragraph 负责连接 TextLayout,请求 TextLayout 根据当前 maxWidth 计算每个字符的 Bounds。
Flutter 会根据 Paragraph 返回的 minWidth / minHeight / maxWidth / maxHeight 信息计算 Text / RichText 在 UI 中的相对布局。
TextLayout
在本方案中,我们没有使用 Harfbuzz 或者 ICU 库进行字符测量和排版。
我们使用的是 Web Canvas 提供的 measureText API,该 API 可以返回指定字体(默认是系统字体)的指定字符串宽度信息,高版本的 Chrome 甚至可以返回逐字宽度和逐字高度。
一个完整的字符测量结果如下图所示:
只要我们可以获得每一字符的四要素,就可以计算出一段文本的排版结果。
然而,并非所有浏览器都会返回四要素,此时,我们可以通过 measureText('M')
的 width
值,近似计算得到其余值。
一个棘手的问题是,多行文本的排版处理。由于 Web Canvas measureText API 仅返回单行文本的宽高,不返回多行文本的任何信息,也就是它不负责换行的计算,我们需要自行实现换行策略。
我们是这样处理换行的:
当然,在实际过程中,需要处理的情况会更多,例如 maxLines、标点符号、英文单词分拆等,具体处理逻辑就不在这里详述了。
TextRenderer
最后,我们需要将 Paragraph 绘制到离屏 Canvas 上,我们仍然使用 Web Canvas 2D,使用 fillText
方法。
绘制过程就是将上述 Layout 的过程重复一遍,将逐个 Span 对应的 Glyph 使用指定的 fillStyle
绘制到画布上,并不复杂,感兴趣的同学可以从 MiniTex 的源码中了解,包括 Decoration 的绘制也是一样的。
在文本绘制完成后,只需要 getImageData,接着使用 CanvasKit.MakeImage
创建一个 SkImage
就可以绘制到 Flutter 界面了。
这里有一个小问题,measureText
会返回浮点类型的 x,y,w,h,如果直接使用浮点数 fillText
,在 Flutter 界面中会看到一大堆锯齿文本,解决方法是在 fillText
前,通过 Math.ceil
转换为整数。
五、MiniTex 现状
目前 MiniTex 已完全跑通流程,并支持大部分 TextStyle,尚有 letterSpacing / lineSpacing 等特性待后续实现。
已在 GitHub 开源 github.com/mpflutter/m...
已集成到 MPFlutter 2.3.0 版本,并支持 Flutter Web 使用,欢迎体验。
在使用 MiniTex 后,MPFlutter 初始包大小是 3.5M,文本渲染相关功能正常,收益显著。
六、体验方法
MPFlutter
MPFlutter 2.3.0 版本已内置 MiniTex,你只需要在《编写第一个 MPFlutter 应用》下载 MiniTex 版本的 awesome_project 就可以开始体验。
如果不希望从模板工程开始,可以升级相关依赖到 2.3.0 版本,然后:
- 在
main.dart
中配置fontFamily
。
scala
// main.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// 设置 ThemeData 的 fontFamily 和 fontFamilyFallback 为以下值。
fontFamily: "MiniTex",
fontFamilyFallback: ["MiniTex"],
),
// ...
);
}
}
- 在
pubspec.yaml
中删除字体依赖。
yaml
# pubspec.yaml
name: awesome_project
description: A new Flutter project.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.1.5 <4.0.0'
dependencies:
flutter:
sdk: flutter
mpflutter_core: ^2.3.0
mpflutter_build_tools: ^2.3.0
mpflutter_wechat_api: ^2.2.0
mpflutter_wechat_mapview: ^0.0.3
flutter:
uses-material-design: true
# 使用 MiniTex,不再需要加载任何字体,注释后,字体不会打包到小程序包中。
# fonts:
# - family: Roboto
# fonts:
# - asset: fonts/Roboto-Regular.ttf
# - asset: fonts/Roboto-Bold.ttf
# - family: Noto Sans SC
# fonts:
# - asset: fonts/NotoSansSC-Regular.ttf
Web
MiniTex 也支持在 Web 中使用,你可以直接从模板工程开始。
当然,你也可以将 MiniTex 添加到已有的工程中。
- 复制模板工程中的
web/minitex.min.js
到你的工程对应目录下。 - 复制模板工程中的
web/index.html
到你的工程对应目录下。 - 参考 MPFlutter 的方法,配置
main.dart
和pubspec.yaml
。
本文原作者是 PonyCui,原文出处 mpflutter.feishu.cn/wiki/B62mwA... 你可以自由转载该文章,但请保留该原文信息。