22、WebKit 的字体与文本渲染:字体加载与回退、文本布局(Shaper)、字形渲染与抗锯齿、中文字体渲染的坑
字体和文本渲染,说实在的,是浏览器里最容易被低估的模块。很多人觉得不就是显示几个字嘛,有什么难的?但我在做内核优化的那几年,光是字体回退这一个问题,就踩了不下十次坑。你想想看,一个页面里可能同时有英文、中文、日文、emoji,甚至还有生僻字,系统字体又不一定全,怎么保证每个字都能正确显示?这就是我们今天要聊的核心。
字体加载与回退机制
WebKit 的字体系统,核心思路是「按需加载,逐级回退」。说白了,就是先看你指定的字体有没有,没有就换下一个,直到找到能显示这个字符的字体为止。
我习惯把字体回退链想象成一个链表。每个 FontFamily 节点都指向下一个备选字体。WebKit 在 FontCache 里维护了全局的字体实例池,避免重复加载。
核心流程:
- CSS 指定 font-family,比如
font-family: "PingFang SC", "Microsoft YaHei", sans-serif; - WebKit 按顺序尝试每个字体名,检查是否已加载到缓存中
- 如果字体未加载,触发异步加载流程(通过 CoreText 或 FreeType)
- 加载完成后,检查该字体是否包含当前字符的 glyph
- 如果不包含,继续尝试下一个字体
- 如果所有指定字体都不包含,最终 fallback 到系统默认字体
这里有个关键点:字体回退是按字符级别进行的。也就是说,同一段文字里,英文可能用 Arial,中文用 PingFang,日文用 Hiragino。这在 FontFallbackList 里实现,每个字符都会走一遍回退链。
我曾经踩过的坑: 在 Android 4.4 时代,系统字体对 emoji 的支持很差。如果页面里混用了 emoji 和中文,WebKit 会频繁触发字体回退,导致滚动时明显卡顿。后来我们做了个优化:在字体回退链里把 Noto Color Emoji 提前,减少回退次数。
文本布局:Shaper 的工作原理
文本布局,说白了就是把一串 Unicode 字符变成一个个可以绘制的位置。这个工作由 FontShaper 完成。嗯,这里要注意,Shaper 不是简单的「一个字一个字排过去」,它要处理很多复杂情况。
WebKit 使用 HarfBuzz 作为默认的 Shaper 引擎。HarfBuzz 是开源的,支持几乎所有语言的复杂文本布局,比如阿拉伯语的连字、泰语的上下标、印地语的 repha 等。
Shaper 的核心输出是 GlyphBuffer,里面包含每个 glyph 的 ID、位置、宽度等信息。我画了一张图,帮你理解整个流程:
Shaper 的工作可以拆成三步:
- 字形映射: 把字符代码映射到字体里的 glyph ID。比如字母 'A' 在某个字体里可能是 glyph 41。
- 字形定位: 计算每个 glyph 的 x/y 偏移。对于阿拉伯语,同一个字符在词首和词尾的写法不同,位置也不同。
- 字距调整: 根据上下文调整字符间距,比如 'AV' 两个字母通常需要紧贴一些。
个人经验: 我在优化微信浏览器时发现,HarfBuzz 对中文的 Shaping 其实很快,因为中文字符大多是独立方块字,不需要复杂的连字处理。但一旦混入日文假名或韩文谚文,Shaping 时间会翻倍。所以如果你的页面主要面向中文用户,可以放心用 HarfBuzz。
字形渲染与抗锯齿
字形渲染,就是把 glyph 的轮廓变成屏幕上的像素点。这里涉及两个核心技术:抗锯齿 和 次像素渲染。
抗锯齿的原理很简单:在字形边缘的像素上,根据覆盖面积计算透明度,让边缘看起来平滑。WebKit 在 Android 上使用 FreeType 的 FT_RENDER_MODE_NORMAL 模式,开启 256 级灰度抗锯齿。
次像素渲染更高级一些。它利用 LCD 屏幕的 RGB 子像素排列,在水平方向把每个像素拆成红绿蓝三个子像素,理论上可以把水平分辨率提高三倍。但次像素渲染有个大坑:一旦屏幕旋转,子像素排列方向变了,文字会出现彩色边缘。
我曾经踩过的坑: 在某个平板项目上,我们默认开启了次像素渲染,结果用户旋转屏幕后,文字边缘出现了明显的红蓝条纹。后来我们加了个监听:屏幕方向变化时,强制重新初始化 FreeType 的 LCD 滤波器,并清空字体缓存。这才解决问题。
WebKit 在 Android 上的抗锯齿策略可以总结为:
| 渲染模式 | 适用场景 | 性能开销 |
|---|---|---|
| FT_RENDER_MODE_MONO | 低分辨率屏幕、终端字体 | 低 |
| FT_RENDER_MODE_NORMAL | 默认模式,256 级灰度抗锯齿 | 中 |
| FT_RENDER_MODE_LCD | 高分辨率 LCD 屏幕,次像素渲染 | 高 |
中文字体渲染的坑
中文字体渲染,可以说是 WebKit 在 Android 上最头疼的问题之一。我总结了几个最常见的坑:
1. 字体回退导致的白屏
有些中文字体文件很大,比如思源黑体 Regular 就有 8MB 以上。如果页面首次加载时触发字体回退,WebKit 需要下载整个字体文件,这期间文字区域会显示空白。我曾经在某个新闻客户端上看到,用户打开文章后,正文区域白屏了 3 秒才显示文字——就是因为字体文件太大,下载阻塞了渲染。
解决方案: 使用 font-display: swap,让浏览器先用 fallback 字体显示,等自定义字体加载完成后再替换。或者使用字体子集化,只包含页面用到的字符。
2. 行高不一致
中文字体和英文字体的 ascent/descent 值不同。如果混排时没有做对齐处理,中文和英文的行高会不一致,导致文字上下跳动。WebKit 的 FontMetrics 类里有个 ascent 和 descent 字段,但不同字体的值差异很大。
我习惯的做法是:在 FontPlatformData 初始化时,统一把 ascent 和 descent 对齐到某个基准值,比如 ascent = 0.8 * emSize,descent = 0.2 * emSize。这样不管用什么字体,行高都保持一致。
3. 生僻字显示为方框
有些生僻字(比如「𪚥」)在系统字体里没有对应的 glyph,WebKit 会显示一个方框(tofu)。这个问题在 Android 原生字体里尤其严重,因为 Android 的 Noto Sans CJK 只覆盖了常用汉字。
我曾经踩过的坑: 某个金融 App 的用户名里包含生僻字,结果在列表页显示为方框。我们排查后发现,WebKit 的字体回退链里没有包含支持扩展 B 区汉字的字体。后来我们在 FontCache 里手动注册了「花园明朝」字体,专门用于生僻字回退。
4. 字体渲染性能
中文字体因为笔画复杂,每个 glyph 的轮廓点数量远多于英文字体。一个「龍」字可能有 200 多个轮廓点,而英文字母 'A' 只有 10 个左右。这意味着 FreeType 在渲染中文字体时,需要更多的 CPU 时间来做轮廓填充和抗锯齿。
我建议在低端设备上,对中文字体使用 FT_RENDER_MODE_NORMAL 而不是 FT_RENDER_MODE_LCD,可以节省约 30% 的渲染时间。虽然视觉效果差一点,但流畅度更重要。
总结一下中文字体渲染的避坑指南:
- 字体文件尽量子集化,减少加载时间
- 使用
font-display: swap避免白屏 - 统一行高基准值,避免混排时跳动
- 注册扩展字体,覆盖生僻字
- 低端设备关闭次像素渲染,保证流畅度
字体渲染这件事,说难不难,说简单也不简单。你只要记住一个原则:永远不要假设用户的设备上有你想要的字体。做好回退、做好缓存、做好性能监控,你的页面才能在各种设备上都显示得漂漂亮亮。
公众号:蓝海资料掘金营,微信deep3321