Android 文本渲染:Typeface、Minikin、Skia 与测量

(更新于 )

Android 文本不存在“字形缓存命中必定快 50 倍”这样的通用结论。开销取决于文字脚本和 shaping、字体文件、文本长度、设备 GPU/驱动、缓存状态,以及帧究竟受 CPU 还是 GPU 限制。可靠的方法是理解链路、避免在热点 UI 代码中反复创建字体,再为实际页面采集 trace。

概念上,TextView 或 Compose Text 的样式先选择 Typeface;文本栈按字体覆盖范围做匹配与回退(Minikin 属于这一层),处理复杂脚本的 shaping,再经由以 Skia 为基础的图形栈绘制字形。系统字体文件和回退顺序由设备配置决定,不能跨 Android 版本或 OEM 断言“sans-serif 永远是某个文件”。

把 Typeface 当作“字体家族与样式”请求

Typeface.create("sans-serif", Typeface.NORMAL) 选择系统字体家族和样式,并不指定稳定的物理字体文件。Typeface 还支持应用字体数据、TTC 索引、字重、斜体和变体设置。Typeface.create(Typeface, style) 在 API 27 及更低版本不是线程安全的;若兼容这些版本,应在主线程创建/缓存或自行串行化。

需要视觉一致性时,将必须的字体随应用打包,并通过资源引用:

<!-- res/font/app_text.xml -->
<font-family xmlns:android="http://schemas.android.com/apk/res/android">
    <font android:fontStyle="normal"
          android:fontWeight="400"
          android:font="@font/app_text_regular" />
    <font android:fontStyle="normal"
          android:fontWeight="700"
          android:font="@font/app_text_bold" />
</font-family>
val typeface = ResourcesCompat.getFont(context, R.font.app_text)
title.typeface = typeface

这能让该家族覆盖的字形呈现可复现,但不会消除回退:缺失 code point 仍需回退字体。必须测试实际语言、emoji、RTL 文本和无障碍字体缩放;只看英文截图无法验证多语言字体方案。

回退与 shaping 首先是正确性问题

Minikin 会根据覆盖范围、语言、样式与变体从字体集合中选择字体,并与文本栈的 shaping 协作,使字符序列形成依赖上下文的字形 run。阿拉伯文、印度文字、连字、组合音标与 emoji 不能安全地按“一字符一字形”处理。

因此有两条工程规则:

  1. 不要按 code point 逐个绘制来实现字体回退;使用 TextView、Compose Text 或具备 shaping 能力的文本 API。
  2. 不要为追逐未经测量的查找开销而把系统回退链替换成很窄的自定义字体;这可能让正常用户文本显示为 tofu。应补齐产品真正需要的字体与语言覆盖。

API 29+ 的 Font、FontFamily 与 Typeface.CustomFallbackBuilder 可供高级场景组装受控字体集合。API 文档警告:同一 FontFamily 内不同样式的字体应有相同 code point 覆盖,否则某个样式可能显示 tofu。这是正确性约束,不是微优化。

可下载字体:不要把首帧绑到网络上

可下载字体可降低 APK 的字体体积,并通过 provider 获取。是否可用取决于 provider、证书、设备服务和网络,所以不能承诺首帧就拿到字体。页面应有本地/系统回退样式,并异步请求后安全更新 UI:

val request = FontRequest(
    "com.google.android.gms.fonts",
    "com.google.android.gms",
    "name=Roboto",
    R.array.com_google_android_gms_fonts_certs
)

FontsContractCompat.requestFont(
    context, request,
    object : FontsContractCompat.FontRequestCallback() {
        override fun onTypefaceRetrieved(typeface: Typeface) {
            title.typeface = typeface
        }
        override fun onTypefaceRequestFailed(reason: Int) {
            // 保留已打包/系统回退,并记录不含敏感数据的诊断信息。
        }
    }, Handler(Looper.getMainLooper())
)

请按当前 AndroidX 版本核对 provider 与证书资源。不要编造重试间隔、缓存有效期或首帧收益;这些不是稳定 Android 契约,且会随 provider 和版本改变。

优化前先定位文本卡顿

构造可重复的滚动或首屏场景,在一次改动前后采集 Perfetto/System Trace。观察相关帧中主线程 layout/measure、反复创建 Typeface 或 Span 的分配,以及 RenderThread/GPU 工作:

adb shell perfetto -o /data/misc/perfetto-traces/text.perfetto-trace \
  -t 10s sched freq gfx view wm
adb pull /data/misc/perfetto-traces/text.perfetto-trace

该命令记录调度和图形上下文,不能标记每一次字形缓存命中。配合应用 trace 标注真正昂贵的绑定或文本构建:

Trace.beginSection("bindProductTitle")
try {
    holder.title.text = item.title
} finally {
    Trace.endSection()
}

若 trace 显示重复构造字体或资源,按资源/字重缓存一个有上限的小型 Typeface 集合,并复用不可变 TextAppearance 或 Compose TextStyle。若耗时在长文本、频繁变化文本的段落 layout,先减少无谓 remeasure/recomposition;若主要在 GPU,分析整帧,不要假设字体是唯一原因。

官方资料与延伸阅读