Android 文字渲染三种模式对比:Default / SDFT / SDFT2
MIUI 在 AOSP Skia 基础上扩展了两种 SDF 文字渲染模式(Feature Tag: SF_BugFixUpstream_3),用于解决动画场景下的字形缓存失效问题。
一、三种模式总览
| 维度 | Default (DirectMask) | SDFT (forceSDFT via ViewRootImpl) | SDFT2 (forceSDFT via View) |
|---|
| 来源 | AOSP 原生 | MIUI 扩展 (第一版) | MIUI 扩展 (第二版,当前推荐) |
| 作用域 | 默认行为 | 整个窗口 (ViewRootImpl 级) | 单个 View 及其子树 |
| API | 无 (默认) | ViewRootImpl.setForceSDFT(true) | View.setForceSDFT(true) |
| SystemUI 调用 | — | NotificationUtil.setForceSDFT(view, true) | NotificationUtil.setForceSDFT2(view, true) |
| 光栅化方式 | 按实际 CTM scale 光栅化为位图 | 按固定桶字号光栅化为距离场 | 同 SDFT |
| 缩放方式 | 无缩放 (1:1 像素映射) | GPU shader 插值缩放 | 同 SDFT |
| Atlas 格式 | A8 (灰度覆盖率) | A8 (有符号距离值) | 同 SDFT |
| 传递路径 | — | Path B: ViewRootImpl→RenderProxy→CanvasContext→Canvas | Path A: View.mForceSDFT→子View继承→Paint→Skia |
二、核心机制对比
2.1 光栅化 Strike Key 构成
| Key 组成部分 | Default | SDFT / SDFT2 |
|---|
| Typeface ID | ✅ | ✅ |
| TextSize | ✅ 实际像素值 | ✅ 固定桶值 (32/72/162px) |
| fPost2x2 (CTM scale) | ✅ sk_relax(scale) 进入 key | ❌ 使用 Identity Matrix |
| fontVariationSettings | ✅ | ✅ |
| Subpixel position | ✅ 2-bit 量化 (4种状态) | ❌ 关闭 subpixel |
| Hinting | ✅ | ✅ (但 SDF 通常无 hinting) |
2.2 SubRun 复用条件
| 条件 | Default (CanUseDirect) | SDFT / SDFT2 (matrixInRange) |
|---|
| scale 变化 | 精确浮点比较 — 任何变化都失败 | 范围比较 — 在 [min, max] 内均可复用 |
| translate 变化 | 要求整数差值 | 无要求 (SDF 不依赖像素对齐) |
| 复用范围示例 | scaleX 从 1.0 变为 1.000001 → ❌ miss | scaleX 在 [0.56, 1.6] → ✅ hit |
2.3 GlyphID 编码差异
Default (DirectMask):
SkPackedGlyphID = (subPixelX << 30) | (subPixelY << 28) | (glyphID << 0)
→ 同一字形在不同亚像素位置 = 不同 ID = 不同缓存条目
SDFT / SDFT2:
SkPackedGlyphID = (0 << 30) | (0 << 28) | (glyphID << 0)
→ 同一字形永远 = 同一 ID = 同一缓存条目
三、属性变化对三种模式的影响
3.1 Scale 变化(父容器 setScaleX/Y 动画)
| 场景 | Default | SDFT / SDFT2 |
|---|
| scale 1.0 → 0.97 (面板联动) | ❌ 每帧 Strike miss | ✅ 始终命中 |
| 原因 | sk_relax(0.97) ≠ sk_relax(1.0) → fPost2x2 变化 → 新 Strike → 全字形重光栅化 | StrikeSpec 使用 SkMatrix::I() → fPost2x2 始终为 [1,0,0,1] → Strike 不变 |
| 耗时 | ~600us/帧 (50个CJK字形) | ~3us/帧 (纯 Atlas hit) |
| 每帧行为 | FreeType 逐字形光栅化 + Atlas 上传 | 仅填充顶点 + GPU shader 缩放 |
具体示例:
面板上滑: SharedNotificationContainer.scaleX 从 1.0 递减到 0.9
每帧 delta ≈ 0.0025 > 1/1024 (sk_relax 阈值)
Default: 每帧 sk_relax 跳变 → 新 Strike → 所有字形重光栅化
Frame 1: sk_relax(0.997) = 0.997070 → Strike A → 光栅化 50 字形 = 600us
Frame 2: sk_relax(0.994) = 0.994141 → Strike B → 光栅化 50 字形 = 600us
Frame 3: sk_relax(0.992) = 0.992188 → Strike C → 光栅化 50 字形 = 600us
... 每帧 600us,100 帧累计 39.5ms
SDFT/SDFT2: Strike 使用 Identity → scale 不影响 key
Frame 1: Strike = (font, 32px, I) → 首帧光栅化 = 600us
Frame 2: Strike 相同 → Atlas hit = 3us
Frame 3: Strike 相同 → Atlas hit = 3us
... 首帧后每帧 3us,100 帧累计 0.9ms
3.2 TextSize 变化(ValueAnimator + setTextSize)
| 场景 | Default | SDFT / SDFT2 |
|---|
| textSize 14sp → 16sp 动画 (120帧) | ❌ 每帧新 Strike | ⚠️ 桶内命中,跨桶 miss |
| 原因 | 每个 float textSize 值 = 新 Descriptor → 新 Strike | 固定桶值(32px)覆盖 14-32sp 范围 → 桶内永不 miss |
| 耗时 | ~600us × 120帧 = 72ms | 首帧 600us + 后续 3us × 119帧 ≈ 0.96ms |
具体示例:
textSize 动画: 14sp → 16sp, 密度=2x → 实际 28px → 32px
Default:
Frame 1: textSize=28.0px → Strike(28.0) → 光栅化
Frame 2: textSize=28.03px → Strike(28.03) → 光栅化 (完全不同的 Strike!)
Frame 3: textSize=28.06px → Strike(28.06) → 光栅化
... 每帧光栅化
SDFT/SDFT2:
14sp-16sp 对应设备 28-32px
桶选择: deviceSize ≤ 32 → 桶 32px
Frame 1: dfFont.setSize(32) → Strike(32) → 光栅化
Frame 2: dfFont.setSize(32) → Strike(32) → Atlas hit!
Frame 3: dfFont.setSize(32) → Strike(32) → Atlas hit!
... 始终在同一桶内
但如果动画跨桶 (如 30sp → 40sp, 设备 60px → 80px):
桶选择: 60px → 桶72, 80px → 桶72 → 仍然命中同一桶!
(只有跨越 32/72/162 边界时才会换桶)
3.3 字重变化(fontVariationSettings wght 动画)
| 场景 | Default | SDFT / SDFT2 |
|---|
| wght 400 → 700 动画 | ❌ 每帧新 Strike | ❌ 每帧新 Strike |
| 原因 | fontVariationSettings 进入 Typeface fingerprint → Descriptor 变化 | 同样失效! fontVariationSettings 影响的是 Typeface 本身,不是 matrix |
| 耗时 | ~150us/帧 (时钟数字) | ~150us/帧 (同样失效) |
关键区别:SDFT 无法保护字重动画!
AllInOne 时钟: wght 从 380 动画到 700 (Folme spring)
Default:
Frame 1: Typeface(MiSans, wght=380) + size=48px + matrix=[1,0,0,1]
→ Strike(MiSans-wght380, 48, [1,0,0,1]) → 光栅化
Frame 2: Typeface(MiSans, wght=382) + size=48px + matrix=[1,0,0,1]
→ Strike(MiSans-wght382, 48, [1,0,0,1]) → 光栅化 (新 Strike!)
SDFT/SDFT2:
Frame 1: Typeface(MiSans, wght=380) + dfSize=72px + matrix=I
→ Strike(MiSans-wght380, 72, I) → 光栅化
Frame 2: Typeface(MiSans, wght=382) + dfSize=72px + matrix=I
→ Strike(MiSans-wght382, 72, I) → 光栅化 (新 Strike! Typeface 变了!)
两者完全相同! SDFT 只能屏蔽 matrix/subpixel 影响,无法屏蔽 Typeface 变化。
修复方案:字重动画只能通过 Hardware Layer 保护(缓存子树渲染结果,字重变化不触发重绘)。
3.4 Alpha 变化(父容器 setAlpha 动画)
| 场景 | Default | SDFT / SDFT2 |
|---|
| alpha 1.0 → 0.0 + hasOverlappingRendering=true | ⚠️ 触发 saveLayer,但 blob 可复用 | ✅ 同样可复用 |
| alpha 1.0 → 0.0 + hasOverlappingRendering=false | ⚠️ AlphaFilterCanvas replay,blob 可复用 | ✅ 同样可复用 |
| alpha 变化是否影响 Strike Key | ❌ 不影响 | ❌ 不影响 |
结论:纯 alpha 变化不会导致 AtlasTextOp 高耗时(不影响 Strike/SubRun),三种模式表现相同。但 alpha 变化产生 damage → 驱动子树重绘 → 如果同时有 scale 变化就会暴露问题。
3.5 Translation 变化(父容器 setTranslationX/Y)
| 场景 | Default | SDFT / SDFT2 |
|---|
| 整数 translate (如 100px → 200px) | ✅ SubRun 可复用 (整数差值) | ✅ 可复用 |
| 非整数 translate (如 100.3px → 200.7px) | ⚠️ SubRun miss (非整数差值) → 重建 SubRun,但 Strike 不变 → 仅 ~20us | ✅ 可复用 (SDF 不检查 translate) |
| 是否触发字形重光栅化 | ❌ Strike Key 不含 translate | ❌ 同样不含 |
结论:translate 变化不影响 Strike(光栅化缓存),只影响 SubRun 复用。Default 模式下非整数偏移会重建 SubRun(~20us),SDFT 完全无影响。
3.6 Rotation 变化(父容器 setRotation)
| 场景 | Default | SDFT / SDFT2 |
|---|
| rotation 0° → 15° | ❌ 每帧 Strike miss | ✅ 始终命中 |
| 原因 | rotation 改变 CTM 的 skewX/skewY → fPost2x2 变化 → 新 Strike | StrikeSpec 使用 Identity → rotation 不影响 |
| 耗时 | 高 (与 scale 动画类似) | ~3us/帧 |
3.7 字体切换(setTypeface 改变字体文件)
| 场景 | Default | SDFT / SDFT2 |
|---|
| 从 Roboto 切换到 NotoSansCJK | ❌ 新 Strike (不同 Typeface) | ❌ 新 Strike (不同 Typeface) |
| 是否有差异 | 无 | 无 |
结论:字体切换在所有模式下都需要重新光栅化,SDFT 无法优化这种情况。
四、综合对比矩阵
4.1 属性变化 × 模式 → 是否 Cache Miss
| 属性变化 | Default | SDFT / SDFT2 | 差异倍数 |
|---|
| Scale 动画 (每帧 delta > 1/1024) | ❌ 每帧全 miss | ✅ 始终 hit | 200x |
| Rotation 动画 | ❌ 每帧全 miss | ✅ 始终 hit | 200x |
| 非整数 Translate | ⚠️ SubRun miss (低开销) | ✅ hit | 5x |
| 整数 Translate | ✅ hit | ✅ hit | 1x |
| TextSize 动画 (桶内) | ❌ 每帧全 miss | ✅ 桶内 hit | 200x |
| TextSize 动画 (跨桶) | ❌ 每帧全 miss | ⚠️ 跨桶 miss | 1x |
| 字重 (wght) 动画 | ❌ 每帧全 miss | ❌ 每帧全 miss | 1x (无效!) |
| fontVariationSettings 变化 | ❌ miss | ❌ miss | 1x (无效!) |
| 字体切换 (setTypeface) | ❌ miss | ❌ miss | 1x (无效!) |
| 纯 Alpha 变化 | ✅ hit | ✅ hit | 1x |
| setText() 内容变化 | ❌ DL 重建 + 新字形 miss | ❌ 同样 miss (但已缓存字形可复用) | 1x |
4.2 SDFT 有效 vs 无效 场景清晰划分
SDFT 有效 (能消除 cache miss):
├─ CTM matrix 的 scale 分量变化 (fPost2x2)
├─ CTM matrix 的 skew/rotation 分量变化 (fPost2x2)
├─ 亚像素位置 (subpixel position) 变化
├─ textSize 在同一桶内的变化
└─ 总结: 凡是影响 fPost2x2 / subpixel 的 → SDFT 有效
SDFT 无效 (无法消除 cache miss):
├─ Typeface 变化 (fontVariationSettings / wght / wdth / hght)
├─ textSize 跨桶变化 (32↔72↔162)
├─ setText() 引入从未出现过的字形
└─ 总结: 凡是影响 Typeface identity / 引入新 glyphID 的 → SDFT 无效
五、SDFT vs SDFT2 作用域对比
5.1 API 差异
// SDFT (第一版) — 作用于整个窗口
NotificationUtil.setForceSDFT(view, true);
// 内部: view.getViewRootImpl().setForceSDFT(true)
// 影响: 该窗口内所有文字渲染都走 SDF 路径
// SDFT2 (第二版) — 作用于指定 View 子树
NotificationUtil.setForceSDFT2(view, true);
// 内部: view.setForceSDFT(true)
// 影响: 仅该 View 及其子 View 的文字走 SDF 路径
5.2 传递路径对比
SDFT (ViewRootImpl 路径, Path B):
View.getViewRootImpl().setForceSDFT(true)
→ HardwareRenderer.nSetForceSDFT(true)
→ RenderProxy::setForceSDFT(true)
→ CanvasContext::setForceSDFT(true)
→ SkiaPipeline::setForceSDFT(true)
→ canvas->setForceSDFT(true) [每帧渲染前设置]
→ TextBlobRedrawCoordinator:
if (canvas->getForceSDFT()) skPaint.setForceSDFT(true)
作用: 所有经过该 Canvas 绘制的文字都启用 SDF
SDFT2 (View 路径, Path A):
targetView.setForceSDFT(true)
→ targetView.mForceSDFT = true
→ 子 View draw 时: child.mPreForceSDFT = parent.getForceSDFT() [自动继承]
→ TextView.onDraw():
mTextPaint.setForceSDFT(getForceSDFT()) [per-draw 设置]
→ Paint.nSetForceSDFT(true)
→ SkPaint.fForceSDFT = true
→ SubRunControl::isSDFT(): paint.getForceSDFT() = true
作用: 只有 targetView 子树中的 TextView 文字启用 SDF
5.3 使用场景差异
| 维度 | SDFT (ViewRootImpl) | SDFT2 (View) |
|---|
| 粒度 | 粗 (整个窗口) | 细 (单个 View 子树) |
| 副作用 | 窗口内所有文字都变 SDF (包括不需要的) | 只影响目标 View |
| 适用场景 | 全局动画 (如整个页面 transition) | 局部动画 (如单个通知行的入场) |
| 性能开销 | 大量文字切换 SDF 的首次光栅化开销 | 仅目标 View 的文字切换 |
| 文字质量 | 全窗口可能出现 SDF 模糊 | 只在目标区域 |
| 当前使用方 | 无 (已被 SDFT2 替代) | MiuiNotificationPanelAnimControllerImpl |
5.4 两路径汇合点
两条路径最终在 TextBlobRedrawCoordinator.cpp:48-49 汇合:
// 路径 B 生效:
if (canvas->getForceSDFT()) {
skPaint.setForceSDFT(canvas->getForceSDFT());
}
// 之后 SubRunControl::isSDFT() 检查 paint.getForceSDFT()
// 路径 A 生效:
// Paint 上已经带有 forceSDFT=true (来自 TextView.onDraw 设置)
// SubRunControl::isSDFT() 同样检查 paint.getForceSDFT()
设计意图:Path B 是兜底保障——即使某些非 TextView 的文字绘制没有走 Path A(如自定义 View 直接 canvas.drawText),Path B 仍能覆盖。
5.5 已知 Bug:View.setForceSDFT(false) 无效
// View.java:20502-20505
public void setForceSDFT(boolean forceSDFT) {
Log.d(VIEW_LOG_TAG, "View setForceSDFT: " + forceSDFT);
mForceSDFT = true; // ★ BUG: 写死 true,忽略了 forceSDFT 参数!
}
影响:一旦对某个 View 调用 setForceSDFT(true),后续调用 setForceSDFT(false) 无法关闭。mForceSDFT 永远为 true。
当前规避方式:SDFT2 通过子 View 的 mPreForceSDFT 在下一次 draw() 时不再从 parent 继承来间接”关闭”——但 mForceSDFT 本身仍为 true。这是一个需要修复的 Framework bug。
六、性能数据对比(来自实际 Trace)
DrawFrames 4267789 — 面板联动动画场景
| View | Default 耗时 | SDFT2 保护后 | 节省 |
|---|
| 通知 TextView (50字形) | 606us/帧 | ~3us/帧 | 99.5% |
| KeyguardIndicationTextView (3字形) | 367us/帧 | ~3us/帧 | 99.2% |
| ClassicTextAreaView (字重动画) | 144us/帧 | 144us/帧 | 0% (无效!) |
| 100帧累计 | 39.47ms | <2ms | 95% |
ClassicTextAreaView 无法被 SDFT 保护 — 因为问题来自 fontVariationSettings (wght/wdth/hght) 变化,不是 matrix 变化。
七、决策指南
7.1 选择哪种保护方案
AtlasTextOp 高耗时
│
├─ 原因是 CTM scale/rotation 变化?
│ └─ YES → 使用 setForceSDFT2 (最优方案)
│
├─ 原因是 textSize 动画?
│ └─ YES → 使用 setForceSDFT2 (桶内有效)
│ 或 改用 View.SCALE_X/Y 模拟缩放
│
├─ 原因是 fontVariationSettings 动画 (字重/字宽)?
│ └─ YES → setForceSDFT2 无效!
│ → 使用 Hardware Layer (setLayerType)
│ → 或 停止动画期间的字重变化
│
├─ 原因是 Legacy Animation (TranslateAnimation 等)?
│ └─ YES → 替换为 ObjectAnimator (根本解决)
│ → 或 setForceSDFT2 (缓解,DL 仍重建)
│
└─ 原因是首帧大量新字形?
└─ YES → 预热 (提前触发光栅化)
→ setForceSDFT2 无帮助 (首帧仍需光栅化)
7.2 组合使用推荐
| 场景 | 推荐方案 | 原因 |
|---|
| 面板联动 (scale 0.9~1.0) | SDFT2 | 消除 fPost2x2 影响 |
| 通知入场 (scale + alpha) | SDFT2 + HW Layer | SDFT2 消除 scale miss, HW Layer 避免重绘 |
| 时钟字重动画 | HW Layer | SDFT2 无效,必须缓存渲染结果 |
| Legacy Animation | 替换为 ObjectAnimator | 根本解决 invalidate 问题 |
| 锁屏解锁 (scale 快速缩小) | SDFT2 | 短时间密集 scale 变化 |
八、文字质量对比
| 维度 | Default (DirectMask) | SDFT / SDFT2 |
|---|
| 小字号 (<14sp) | ★★★★★ 像素完美 | ★★★ 可能模糊 (SDF 分辨率不足) |
| 中等字号 (14-32sp) | ★★★★★ | ★★★★ 略有差异但可接受 |
| 大字号 (>32sp) | ★★★★ | ★★★★★ SDF 缩放更平滑 |
| LCD 子像素渲染 | ✅ 支持 | ❌ 不支持 (SDF 只有 A8) |
| Hinting (像素对齐) | ✅ 完整 hinting | ⚠️ 通常关闭 hinting |
| 动画中视觉稳定性 | ❌ 可能出现闪烁 (缓存切换) | ✅ 平滑 (始终同一纹理) |
实际影响:在 SystemUI 的典型字号 (12-16sp) 下,SDF 模式的视觉差异非常微小,用户几乎无法感知。动画期间的视觉稳定性反而更好(无缓存切换导致的微闪)。
九、源码索引
| 模块 | 文件 | 关键函数/行 |
|---|
| View 设置 | View.java:20494-20513 | setForceSDFT(), getForceSDFT(), mPreForceSDFT |
| 子 View 继承 | View.java:27024-27026 | mPreForceSDFT = parent.getForceSDFT() |
| TextView 应用 | TextView.java:9418 | mTextPaint.setForceSDFT(getForceSDFT()) |
| Paint JNI | Paint.cpp:1196-1198 | paint->setForceSDFT(forceSDFT) |
| ViewRootImpl 路径 | ViewRootImpl.java:561-566 | mThreadedRenderer.setForceSDFT() |
| RenderProxy | RenderProxy.cpp:804-805 | mContext->setForceSDFT() |
| CanvasContext | CanvasContext.cpp:1349 | mRenderPipeline->setForceSDFT() |
| Pipeline 注入 | SkiaVulkanPipeline.cpp:108 | canvas->setForceSDFT() |
| 路径汇合 | TextBlobRedrawCoordinator.cpp:48-49 | skPaint.setForceSDFT(canvas->getForceSDFT()) |
| 核心判断 | SubRunControl.cpp:71-88 | isSDFT() — forceSDFT 绕过字号限制 |
| SDF 固定字号 | SubRunControl.cpp:131 | dfFont.setSize(dfMaskSize) |
| SDF Identity | SubRunContainer.cpp:1395 | MakeMask(..., SkMatrix::I()) |
| SDF 无 subpixel | SubRunControl.cpp:132 | dfFont.setSubpixel(false) |
| Default 精确比较 | VertexFiller.cpp:98-101 | CanUseDirect() — float == 比较 |
| SDF 范围复用 | SubRunControl.cpp:145-148 | matrixInRange() — 范围比较 |
| NotificationUtil | NotificationUtil.java:276-301 | setForceSDFT() vs setForceSDFT2() |