TextView Scale 与 AtlasTextOp 耗时的触发条件分析

核心结论:不是有 scale 就会产生 AtlasTextOp 耗时。需要多个条件同时满足才会触发每帧字形重光栅化。


判定决策树

Scale 导致 AtlasTextOp 高耗时?
│
├─ ① scale 是否在变化(每帧不同)?
│    └─ 否 → ❌ 不产生耗时(固定 scale 只有首帧光栅化,后续帧缓存命中)
│
├─ ② sk_relax(scale) 是否跳到新档位?(1/1024 精度量化)
│    └─ 否 → ❌ Strike key 相同,缓存命中
│
├─ ③ 该 View 是否有 HW Layer 保护?
│    └─ 是 → ❌ Layer 缓存子树渲染结果,scale 作为 GPU transform 应用于 texture
│
├─ ④ 是否开启了 forceSDFT?
│    └─ 是 → ❌ SDF 模式用固定字号光栅化,GPU shader 做缩放,Strike key 不含 scale
│
├─ ⑤ 该 View 是否在这一帧的 damage region 中(即是否被渲染)?
│    └─ 否 → ❌ 不参与渲染,不触发 AtlasTextOp
│
└─ ①②③④⑤ 全部满足 → ✅ 产生高耗时(每帧全量字形重光栅化)

场景对照表

场景产生耗时?原因
view.setScaleX(0.97) 固定值,首帧✅ 仅首帧首次光栅化该 scale 下的字形,后续帧缓存命中
view.setScaleX(0.97) 固定值,后续帧Strike 已缓存,Atlas hit ~5us
动画中 scale 每帧变化 + 无 layer + 无 forceSDFT✅ 每帧每帧新 Strike Descriptor → 全字形重光栅化
动画中 scale 每帧变化 + 有 HW LayerLayer 缓冲子树,scale 只做 GPU texture 变换
动画中 scale 每帧变化 + forceSDFT=trueSDF 用固定字号(32/72/162px),scale 不进入 Strike key
父容器 scale 变化但子 View 不在 damage region不参与渲染,AtlasTextOp 不会被执行
scale 变化量 < 1/1024 (0.000977)sk_relax 量化后值不变,Strike key 相同
scale 变化但 View 内容未变 + hasOverlappingRendering=true + alpha<1promotedToLayer 自动升级为 HW Layer

底层机制详解

1. Scale 如何进入 Strike Cache Key

View.setScaleX(0.97)
  → RenderNode.scaleX = 0.97
  → 渲染时 Canvas CTM (Current Transform Matrix) 包含 scale
  → Skia 创建 SubRun 时调用 MakeMask(positionMatrix)
  → SkScalerContext::MakeRecAndEffects()
    → rec.fPost2x2[0][0] = sk_relax(positionMatrix.getScaleX())
    → rec.fPost2x2[1][1] = sk_relax(positionMatrix.getScaleY())
  → fPost2x2 写入 SkDescriptor → 成为 Strike Cache 的查找 key

关键源码/home/zbc/pangu/skia/src/core/SkScalerContext.cpp:1104-1118):

if (mask & SkMatrix::kScale_Mask) {
    rec->fPost2x2[0][0] = sk_relax(deviceMatrix.getScaleX());  // scale 进入 key
    rec->fPost2x2[1][1] = sk_relax(deviceMatrix.getScaleY());
}

2. sk_relax 量化机制 — 决定 Strike 是否变化的阈值

// /home/zbc/pangu/skia/src/core/SkScalerContext.cpp:1046-1049
static SkScalar sk_relax(SkScalar x) {
    SkScalar n = SkScalarRoundToScalar(x * 1024);
    return n / 1024.0f;
}

将浮点数量化到 1/1024 ≈ 0.000977 精度:

  • sk_relax(1.000000) = 1.0
  • sk_relax(0.999636) = 1.0(四舍五入后仍为 1024/1024)
  • sk_relax(0.999272) = 0.999023(跳变!1023/1024)
  • sk_relax(0.970000) = 0.970703(与 1.0 完全不同)

阈值规则:当两帧之间 scale 变化 > 1/1024 时,Strike key 跳变 → 缓存 miss。

3. SubRun 复用的精确比较 — 比 sk_relax 更严格

在判断 SubRun 能否复用时,Skia 做的是浮点精确比较,不走 sk_relax:

// /home/zbc/pangu/skia/src/text/gpu/VertexFiller.cpp:90-105
std::tuple<bool, SkVector> VertexFiller::CanUseDirect(
        const SkMatrix& creationMatrix, const SkMatrix& positionMatrix) {
    return {creationMatrix.getScaleX() == positionMatrix.getScaleX() &&  // 精确比较!
            creationMatrix.getScaleY() == positionMatrix.getScaleY() &&
            // ...
            };
}

这意味着:

  • SubRun 重建:只要 scale 有任何浮点变化(即使 sk_relax 后值相同),SubRun 都会被重建
  • 但 Strike 是否 miss:取决于 sk_relax 量化后是否跳变

两者的组合效果:

  • SubRun 每帧重建(精确比较失败)→ 走 MakeMask → 计算 Strike Descriptor
  • 如果 sk_relax 后值没变 → Strike 命中 → 字形直接从缓存取 → 低耗时(~5-20us)
  • 如果 sk_relax 后值变了 → Strike miss → 全量字形重光栅化 → 高耗时(~600us)

4. 三种保护机制对比

保护机制原理优点缺点
HW Layer子树渲染到 GPU texture,scale 作为 texture transform完全消除 AtlasTextOp增加 GPU 内存占用
forceSDFT强制 SDF 渲染,用固定字号(32/72/162px)光栅化,GPU shader 缩放零额外内存,缓存永远命中SDF 在极小字号或极大缩放下可能有模糊
量化 scale将 scale 四舍五入到 1/1024 的倍数不需要改渲染模式动画可能不够平滑

实际案例验证

案例 1: SharedNotificationContainer (本次 Trace)

面板联动: scale 从 1.0 递减到 0.9,每帧变化 ~0.0025
→ 每帧 sk_relax 跳变(0.0025 > 0.000977)
→ 每帧 Strike miss
→ 通知 TextView 50个字形全部重光栅化
→ AtlasTextOp: 606us/帧

无保护 → 高耗时 ✅

案例 2: ExpandableNotificationRow (同一 Trace)

同样有 scale 动画(Folme 动画 0.5→1.0)
但已调用 setForceSDFT2(view, true)
→ SDF 模式,固定字号光栅化
→ Strike key 不受 scale 影响
→ AtlasTextOp: ~3us/帧

有 forceSDFT 保护 → 无耗时 ❌

案例 3: MiuiClock (同一 Trace)

hasOverlappingRendering=false(显式覆写)
文字内容只有 "0-9:+"(约12种字形,高缓存命中率)
无 scale 动画直接作用于该 View
→ AtlasTextOp: 16us/帧(纯 Atlas hit)

无 scale 变化 → 无耗时 ❌


总结

一句话回答:TextView 有 scale 不一定产生 AtlasTextOp 耗时。只有当 scale 持续动画变化(每帧变化 > 1/1024)+ 无 HW Layer + 无 forceSDFT + View 在 damage region 中被渲染 四个条件同时满足时,才会每帧触发全量字形重光栅化。静态 scale(固定值)仅首帧有开销,后续帧全部缓存命中。