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→CanvasPath A: View.mForceSDFT→子View继承→Paint→Skia

二、核心机制对比

2.1 光栅化 Strike Key 构成

Key 组成部分DefaultSDFT / 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 → ❌ missscaleX 在 [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 动画)

场景DefaultSDFT / 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)

场景DefaultSDFT / 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 动画)

场景DefaultSDFT / 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 动画)

场景DefaultSDFT / 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)

场景DefaultSDFT / 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)

场景DefaultSDFT / SDFT2
rotation 0° → 15°每帧 Strike miss✅ 始终命中
原因rotation 改变 CTM 的 skewX/skewY → fPost2x2 变化 → 新 StrikeStrikeSpec 使用 Identity → rotation 不影响
耗时高 (与 scale 动画类似)~3us/帧

3.7 字体切换(setTypeface 改变字体文件)

场景DefaultSDFT / SDFT2
从 Roboto 切换到 NotoSansCJK❌ 新 Strike (不同 Typeface)❌ 新 Strike (不同 Typeface)
是否有差异

结论:字体切换在所有模式下都需要重新光栅化,SDFT 无法优化这种情况。


四、综合对比矩阵

4.1 属性变化 × 模式 → 是否 Cache Miss

属性变化DefaultSDFT / SDFT2差异倍数
Scale 动画 (每帧 delta > 1/1024)❌ 每帧全 miss✅ 始终 hit200x
Rotation 动画❌ 每帧全 miss✅ 始终 hit200x
非整数 Translate⚠️ SubRun miss (低开销)✅ hit5x
整数 Translate✅ hit✅ hit1x
TextSize 动画 (桶内)❌ 每帧全 miss✅ 桶内 hit200x
TextSize 动画 (跨桶)❌ 每帧全 miss⚠️ 跨桶 miss1x
字重 (wght) 动画❌ 每帧全 miss❌ 每帧全 miss1x (无效!)
fontVariationSettings 变化❌ miss❌ miss1x (无效!)
字体切换 (setTypeface)❌ miss❌ miss1x (无效!)
纯 Alpha 变化✅ hit✅ hit1x
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 — 面板联动动画场景

ViewDefault 耗时SDFT2 保护后节省
通知 TextView (50字形)606us/帧~3us/帧99.5%
KeyguardIndicationTextView (3字形)367us/帧~3us/帧99.2%
ClassicTextAreaView (字重动画)144us/帧144us/帧0% (无效!)
100帧累计39.47ms<2ms95%

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 LayerSDFT2 消除 scale miss, HW Layer 避免重绘
时钟字重动画HW LayerSDFT2 无效,必须缓存渲染结果
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-20513setForceSDFT(), getForceSDFT(), mPreForceSDFT
子 View 继承View.java:27024-27026mPreForceSDFT = parent.getForceSDFT()
TextView 应用TextView.java:9418mTextPaint.setForceSDFT(getForceSDFT())
Paint JNIPaint.cpp:1196-1198paint->setForceSDFT(forceSDFT)
ViewRootImpl 路径ViewRootImpl.java:561-566mThreadedRenderer.setForceSDFT()
RenderProxyRenderProxy.cpp:804-805mContext->setForceSDFT()
CanvasContextCanvasContext.cpp:1349mRenderPipeline->setForceSDFT()
Pipeline 注入SkiaVulkanPipeline.cpp:108canvas->setForceSDFT()
路径汇合TextBlobRedrawCoordinator.cpp:48-49skPaint.setForceSDFT(canvas->getForceSDFT())
核心判断SubRunControl.cpp:71-88isSDFT() — forceSDFT 绕过字号限制
SDF 固定字号SubRunControl.cpp:131dfFont.setSize(dfMaskSize)
SDF IdentitySubRunContainer.cpp:1395MakeMask(..., SkMatrix::I())
SDF 无 subpixelSubRunControl.cpp:132dfFont.setSubpixel(false)
Default 精确比较VertexFiller.cpp:98-101CanUseDirect() — float == 比较
SDF 范围复用SubRunControl.cpp:145-148matrixInRange() — 范围比较
NotificationUtilNotificationUtil.java:276-301setForceSDFT() vs setForceSDFT2()