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 Layer | ❌ | Layer 缓冲子树,scale 只做 GPU texture 变换 |
| 动画中 scale 每帧变化 + forceSDFT=true | ❌ | SDF 用固定字号(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<1 | ❌ | promotedToLayer 自动升级为 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.0sk_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(固定值)仅首帧有开销,后续帧全部缓存命中。