AtlasTextOp Cache Miss 根因源码求证 — scale 动画导致 Strike Descriptor 失效机制
结论
原报告的核心错误:将 setScaleX/Y 导致的 cache miss 归因于”亚像素偏移变化 → glyph key (glyphID, subpixelPosition) 不匹配”。
正确机制:setScaleX/Y 变化影响的是 Strike 级别的 Descriptor(通过 fPost2x2 字段),而非单个 glyph 的 subpixel position。每次 Strike Descriptor 变化,该 Strike 下所有字形都需要重新光栅化,这就是为什么每帧耗时高达 ~600μs(约 50 个字形全部重绘)。
| 维度 | 原报告说法 | 正确机制 |
|---|---|---|
| 受影响层级 | Glyph 级 (subpixel position) | Strike 级(整个字体配置) |
| cache key 构成 | (glyphID, subpixelX, subpixelY) | Descriptor = (typeface, textSize, fPost2x2, flags…) |
| 量化机制 | 2-bit subpixel quantization (4 种状态) | 1/1024 精度量化(sk_relax) |
| miss 范围 | 个别 glyph 跨越 subpixel bucket | 整个 Strike 全部字形 |
| scale 影响路径 | CTM translate 小数 → subpixel position | CTM scale 分量 → fPost2x2 → Descriptor |
排查背景
针对锁屏上划解锁场景(Trace: perfetto_1780036731.pftrace),KeyguardIndicationTextView 在面板动画期间每帧产生 ~600μs 的 AtlasTextOp 耗时,累计 20.9ms。原报告将根因归结为 “scale 变化 → 亚像素偏移变化 → glyph key miss”,但这个因果链描述不准确。本文通过 Skia/hwui 源码逐层求证,给出正确的机制。
STEP 1: Skia 文字缓存的两级结构
Skia 的 GPU 文字渲染使用两级缓存:
Level 1: Strike (SkStrike)
└── Key = SkScalerContextRec (包含 typeface, textSize, fPost2x2[2x2 matrix], flags...)
└── 一个 Strike 对应一套"在特定变换下光栅化的字形集合"
Level 2: Glyph (SkPackedGlyphID)
└── Key = (glyphID, 2-bit subpixelX, 2-bit subpixelY)
└── 在一个 Strike 内,每个字形根据亚像素位置有不同的缓存版本
关键区别:
- Strike miss:整个 Strike 不存在 → 所有字形都要重新光栅化(开销大)
- Glyph miss:Strike 存在,但个别字形因 subpixel position 变化而 miss(开销小)
STEP 2: scale 如何进入 Strike Descriptor
2.1 SubRun 创建时调用 MakeMask
当 TextBlob 被绘制时,如果 SubRun 不能复用(canReuse=false),会重新创建:
文件:/home/zbc/pangu/skia/src/text/gpu/SubRunContainer.cpp:1530-1531
SkStrikeSpec strikeSpec = SkStrikeSpec::MakeMask(
runFont, runPaint, deviceProps, scalerContextFlags, positionMatrix);这里的 positionMatrix 是完整的 CTM(Current Transform Matrix),包含了 View 层级累乘的所有 transform,包括 setScaleX/Y 设置的 scale。
2.2 MakeMask → MakeRecAndEffects → fPost2x2
文件:/home/zbc/pangu/skia/src/core/SkStrikeSpec.cpp:133-146
SkStrikeSpec::SkStrikeSpec(const SkFont& font, const SkPaint& paint,
const SkSurfaceProps& surfaceProps,
SkScalerContextFlags scalerContextFlags,
const SkMatrix& deviceMatrix) {
SkScalerContextEffects effects;
SkScalerContext::CreateDescriptorAndEffectsUsingPaint(
font, paint, surfaceProps, scalerContextFlags, deviceMatrix,
&fAutoDescriptor, &effects);
// ...
}2.3 deviceMatrix 的 scale 分量写入 fPost2x2
文件:/home/zbc/pangu/skia/src/core/SkScalerContext.cpp:1104-1118
void SkScalerContext::MakeRecAndEffects(..., const SkMatrix& deviceMatrix,
SkScalerContextRec* rec, ...) {
// ...
const SkMatrix::TypeMask mask = deviceMatrix.getType();
if (mask & SkMatrix::kScale_Mask) {
rec->fPost2x2[0][0] = sk_relax(deviceMatrix.getScaleX()); // ★ scale 进入 rec
rec->fPost2x2[1][1] = sk_relax(deviceMatrix.getScaleY()); // ★ scale 进入 rec
} else {
rec->fPost2x2[0][0] = rec->fPost2x2[1][1] = SK_Scalar1;
}
if (mask & SkMatrix::kAffine_Mask) {
rec->fPost2x2[0][1] = sk_relax(deviceMatrix.getSkewX());
rec->fPost2x2[1][0] = sk_relax(deviceMatrix.getSkewY());
} else {
rec->fPost2x2[0][1] = rec->fPost2x2[1][0] = 0;
}
// ...
}SkScalerContextRec 就是 Strike Descriptor 的核心内容(generate_descriptor 在第 1249-1261 行将其作为 kRec_SkDescriptorTag 写入 Descriptor)。
2.4 sk_relax: 1/1024 精度量化
文件:/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;
}sk_relax 将浮点数量化到 1/1024 ≈ 0.000977 精度。这意味着:
sk_relax(1.000000) = 1.0sk_relax(0.999636) = 1.0(0.999636 × 1024 = 1023.627 → round → 1024 → 1024/1024 = 1.0)sk_relax(0.999272) = 0.999023(0.999272 × 1024 = 1023.254 → round → 1023 → 1023/1024)
理论阈值:当 scale < 1023.5/1024 ≈ 0.999512 时,sk_relax 值跳到下一个档位,Strike Descriptor 变化。
STEP 3: SubRun 复用判断 — 为什么每帧都触发重建
3.1 CanUseDirect: 精确比较 scale
文件:/home/zbc/pangu/skia/src/text/gpu/VertexFiller.cpp:90-105
std::tuple<bool, SkVector> VertexFiller::CanUseDirect(
const SkMatrix& creationMatrix, const SkMatrix& positionMatrix) {
SkVector translation = positionMatrix.mapOrigin() - creationMatrix.mapOrigin();
return {creationMatrix.getScaleX() == positionMatrix.getScaleX() && // ★ 精确比较!
creationMatrix.getScaleY() == positionMatrix.getScaleY() &&
creationMatrix.getSkewX() == positionMatrix.getSkewX() &&
creationMatrix.getSkewY() == positionMatrix.getSkewY() &&
!positionMatrix.hasPerspective() && !creationMatrix.hasPerspective() &&
SkScalarIsInt(translation.x()) && SkScalarIsInt(translation.y()),
translation};
}SubRun 复用要求 positionMatrix 的 2x2 部分完全相等(浮点精确比较),且 translation 差是整数。
每帧 scale 都在变(哪怕微小到 0.000364)→ 精确比较失败 → SubRun 每帧重建。
3.2 完整触发流程
帧 N: scale = 0.998xxx
→ TextBlobRedrawCoordinator::drawGlyphRunList() [TextBlobRedrawCoordinator.cpp:39]
→ findOrCreateBlob() [TextBlobRedrawCoordinator.cpp:58]
→ blob->canReuse(paint, positionMatrix) [TextBlobRedrawCoordinator.cpp:72]
→ SubRunContainer::canReuse() [SubRunContainer.cpp:1780]
→ DirectMaskSubRun::canReuse() [SubRunContainer.cpp:777]
→ VertexFiller::deviceRectAndCheckTransform() [VertexFiller.cpp:112]
→ CanUseDirect(fCreationMatrix, positionMatrix) [VertexFiller.cpp:115]
→ scaleX 精确比较失败 → return false
→ TextBlob::Make() [重新创建 SubRun]
→ SubRunContainer::Make()
→ SkStrikeSpec::MakeMask(positionMatrix)
→ sk_relax(scale) → fPost2x2 → Strike Descriptor
STEP 4: 两级 miss 机制解释帧耗时模式
4.1 模式分析
Trace 数据显示每帧都有 ~0.6ms 高耗时(从第 3 帧起),但 chain head 数在 1 和 2 之间交替。
4.2 为什么每帧 Strike 都 miss
如果 scale 每帧递减量 > 1/1024 ≈ 0.000977,则每帧 sk_relax 值都会跳到新档位:
# 模拟验证
scale 每帧递减 delta = 0.001 (> 0.000977)
→ 帧 1: sk_relax(0.999) = 0.999023 (变化!)
→ 帧 2: sk_relax(0.998) = 0.998047 (变化!)
→ 帧 3: sk_relax(0.997) = 0.997070 (变化!)
# 每帧 Strike key 都不同 → 每帧全部字形重光栅化 → 持续 ~600μs实际动画中 scale 从 1.0 下降到 ~0.75(面板完全折叠),在 100 帧内变化 0.25,平均每帧 0.0025 > 0.000977。因此动画中段几乎每帧 Strike 都 miss。
4.3 高低交替的真正原因
- 2 chain heads 的帧:
KeyguardIndicationTextView+ 通知TextView同时被重绘 - 1 chain head 的帧:只有
KeyguardIndicationTextView被重绘 - 交替原因:
SharedNotificationContainer的 transitionAlpha 动画驱动通知区域重绘的频率不同
这与 Strike miss 无关,而是哪些 View 被重绘的交替。
STEP 5: promotedToLayer 与 alpha 的关系(修正原报告说法)
原报告说:
setTransitionAlpha(0.994926)→ RenderNodepromotedToLayer→ 子树每帧被遍历重绘
5.1 promotedToLayer 的实际触发条件
文件:/home/zbc/micode/aospframeworks/base/libs/hwui/RenderProperties.h:609-658
bool promotedToLayer() const {
// ...前置条件检查...
if (!MathUtils::isZero(mPrimitiveFields.mAlpha)
&& mPrimitiveFields.mAlpha < 1
&& mPrimitiveFields.mHasOverlappingRendering) return true; // 条件5
return false;
}条件:alpha ∈ (0, 1) && hasOverlappingRendering == true。
5.2 hasOverlappingRendering 的影响
文件:/home/zbc/micode/aospframeworks/base/libs/hwui/pipeline/skia/RenderNodeDrawable.cpp:740-747
if (properties.getAlpha() < 1) {
if (CC_LIKELY(isLayer || !properties.getHasOverlappingRendering()) || ignoreLayer) {
// 路径A: alphaMultiplier 方式(不创建离屏缓冲)
*alphaMultiplier = properties.getAlpha();
} else {
// 路径B: saveLayerAlpha 方式(离屏缓冲)
canvas->saveLayerAlpha(&bounds, (int)(properties.getAlpha() * 255));
}
}三条路径:
| 条件 | 路径 | 行为 |
|---|---|---|
hasOverlappingRendering=true + alpha<1 | promotedToLayer → 路径A(layer 合成时应用 alpha) | 子树缓存在 layer 中,不每帧重绘 |
hasOverlappingRendering=false + alpha<1 | 路径A(AlphaFilterCanvas 逐 draw call 乘 alpha) | 子树 display list 被 replay |
hasOverlappingRendering=true + 尺寸超限 | 路径B(saveLayerAlpha) | 离屏缓冲 |
5.3 对 KeyguardBottomAreaView 的实际影响
KeyguardBottomAreaView 默认 hasOverlappingRendering=true:
alpha < 1→promotedToLayer()→ 自动提升为 RenderLayer- RenderLayer 缓存子树内容 → alpha 变化不需要 replay 子树 display list
但关键是:scale 变化每帧改变 transform matrix → RenderNode 被标记 dirty → damage region 驱动重绘:
文件:/home/zbc/micode/aospframeworks/base/libs/hwui/RenderNode.cpp:665-702
void RenderNode::pushStagingPropertiesChanges(TreeInfo& info) {
if (mDirtyPropertyFields) {
mDirtyPropertyFields = 0;
damageSelf(info); // 旧 bounds 标脏
info.damageAccumulator->popTransform();
syncProperties(); // 同步新属性
info.damageAccumulator->pushTransform(this);
damageSelf(info); // 新 bounds 标脏
}
}setScaleX/Y → SCALE_X|SCALE_Y dirty flag → pushStagingPropertiesChanges → damage propagation → 子树区域被标脏 → 渲染时需要重绘该区域内的内容。
真正的重绘驱动机制不是 promotedToLayer(那是 alpha 的),而是 scale 变化产生的 damage region。
STEP 6: setForceSDFT 的修复原理(源码验证)
6.1 MIUI 入口
文件:/home/zbc/pangu/skia/src/text/gpu/TextBlobRedrawCoordinator.cpp:48-49
// MIUI MOD: SF_BugFixUpstream_3
if (canvas->getForceSDFT()) {
skPaint.setForceSDFT(canvas->getForceSDFT());
}Canvas 上的 forceSDFT 标志被传给 paint,参与后续 SubRunControl 判断。
6.2 SubRunControl::isSDFT — 强制使用 SDF 路径
文件:/home/zbc/pangu/skia/src/text/gpu/SubRunControl.cpp:71-88
bool SubRunControl::isSDFT(SkScalar approximateDeviceTextSize, const SkPaint& paint,
const SkMatrix& matrix) const {
// MIUI ADD: SF_BugFixUpstream_3
const bool forceSDFT = matrix.hasScale() && paint.getForceSDFT();
// ...
return fAbleToUseSDFT && /* 其他条件 */ &&
(fMinDistanceFieldFontSize <= approximateDeviceTextSize || forceSDFT || ...) &&
(approximateDeviceTextSize <= fMaxDistanceFieldFontSize || forceSDFT);
}当 forceSDFT=true 时,绕过 fMinDistanceFieldFontSize 和 fMaxDistanceFieldFontSize 限制,强制走 SDF 渲染路径。
6.3 SDF 路径为什么不受 scale 影响
文件:/home/zbc/pangu/skia/src/text/gpu/SubRunControl.cpp:91-142
std::tuple<SkFont, SkScalar, SDFTMatrixRange>
SubRunControl::getSDFFont(const SkFont& font, const SkMatrix& viewMatrix, ...) const {
// ...
SkFont dfFont{font};
// 字体 size 被固定为量化后的 dfMaskSize(32/72/162 之一)
dfFont.setSize(dfMaskSize); // ★ 固定 size!
dfFont.setSubpixel(false); // ★ 关闭 subpixel!
dfFont.setEdging(SkFont::Edging::kAntiAlias);
// ...
}SDF 模式下:
- 字体 size 被固定为预定义值(32/72/162px)→
fPost2x2中的 scale 不再影响 Strike key setSubpixel(false)→SkPackedGlyphID不含 subpixel position → glyph key 只有 glyphID- 实际缩放在 GPU shader 中通过距离场插值实现 → 不需要为每个 scale 值重新光栅化
结果:无论 CTM 的 scale 如何变化,Strike Descriptor 保持不变 → 永远命中缓存 → AtlasTextOp 耗时从 ~600μs 降至 ~3μs。
6.4 SDFTMatrixRange — SDF SubRun 的复用条件
文件:/home/zbc/pangu/skia/src/text/gpu/SubRunControl.cpp:145-148
bool SDFTMatrixRange::matrixInRange(const SkMatrix& matrix) const {
SkScalar maxScale = matrix.getMaxScale();
return fMatrixMin < maxScale && maxScale <= fMatrixMax;
}SDF SubRun 的复用不是精确比较 scale,而是检查 scale 是否在一个范围内([dfMaskScaleFloor/textSize, dfMaskScaleCeil/textSize])。只要 scale 没超出这个范围,SubRun 就可以复用 → Strike 不需要重建。
这与 Direct Mask 路径的精确比较形成鲜明对比 — SDF 模式天然容忍 scale 变化。
STEP 7: 完整的 Java → Native 调用链
View.setScaleX → RenderNode → 标脏
View.setScaleX(0.999636) [View.java:20175]
→ invalidateViewProperty(true, false) [通知 parent 重绘]
→ mRenderNode.setScaleX(scaleX) [RenderNode.java:1613]
→ [JNI] nSetScaleX [android_graphics_RenderNode.cpp:813]
→ SET_AND_DIRTY(setScaleX, sx, SCALE_X) [android_graphics_RenderNode.cpp:36-39]
→ RenderProperties::setScaleX(0.999636) [RenderProperties.h:374]
→ mMatrixOrPivotDirty = true [宏 RP_SET_AND_DIRTY]
→ RenderNode::setPropertyFieldsDirty(SCALE_X) [RenderNode.h:165]
→ mDirtyPropertyFields |= SCALE_X
→ invalidateViewProperty(false, true) [触发 redraw]
setForceSDFT 完整路径
View.setForceSDFT(true) [View.java:20502]
→ mForceSDFT = true
→ 子 View draw() 时: mPreForceSDFT = parent.getForceSDFT() [View.java:27025]
→ TextView.onDraw(): mTextPaint.setForceSDFT(getForceSDFT()) [TextView.java:9418]
→ Paint.setForceSDFT(true) [Paint.java:2082]
→ [JNI] nSetForceSDFT [Paint.cpp:1196]
→ SkPaint::setForceSDFT(true)
同时通过 RenderThread 全局路径:
→ ViewRootImpl.setForceSDFT(true)
→ HardwareRenderer.nSetForceSDFT [HardwareRenderer.java:807]
→ [JNI] RenderProxy::setForceSDFT(true) [android_graphics_HardwareRenderer.cpp:1305]
→ CanvasContext::setForceSDFT(true) [CanvasContext.h:305]
→ mRenderPipeline->setForceSDFT(true) [CanvasContext.cpp:1349]
→ SkiaPipeline::mForceSDFT = true [SkiaPipeline.h:195]
→ canvas->setForceSDFT(true) [SkiaVulkanPipeline.cpp:108]
Skia 渲染时:
TextBlobRedrawCoordinator::drawGlyphRunList() [TextBlobRedrawCoordinator.cpp:39]
→ if (canvas->getForceSDFT()) skPaint.setForceSDFT(true) [line 48]
→ SubRunControl::isSDFT() → forceSDFT=true [SubRunControl.cpp:74]
→ getSDFFont() → dfFont.setSize(固定值) [SubRunControl.cpp:131]
→ Strike key 不含 CTM scale → 缓存永远命中
STEP 8: 数值验证
sk_relax 量化阈值计算
sk_relax(x) = round(x * 1024) / 1024.0
当 scale 从 1.0 递减时,第一次跳变发生在:
x * 1024 < 1023.5 → x < 0.999512
验证:
sk_relax(1.000000) = 1.0 → Strike key = A
sk_relax(0.999636) = 1.0 → Strike key = A (首帧不变!)
sk_relax(0.999272) = 0.999023 → Strike key = B (第2帧变化!)
sk_relax(0.998908) = 0.999023 → Strike key = B (未变)
sk_relax(0.998544) = 0.998047 → Strike key = C (又变了)
这说明:
- 首帧(scale=0.999636):
sk_relax量化后仍为 1.0,Strike 命中 → 无高耗时 - 第2帧(scale=0.999272):
sk_relax跳变 → Strike miss → 所有字形重光栅化 → ~600μs
与 trace 完全吻合(frame[0]=目标帧第一次出现高耗时)。
动画全程 Strike 变化次数
scale 范围: 1.0 → ~0.75 (变化 0.25)
量化步长: 1/1024 ≈ 0.000977
预期 Strike 变化次数: 0.25 / 0.000977 ≈ 256 次
trace 100 帧内 52 个高耗时帧 — 考虑到:
1. 动画可能不覆盖全部 100 帧
2. 缓动曲线导致非均匀递减
3. 部分帧 sk_relax 未跳变
STEP 9: 与原报告的完整对比
原报告的因果链(错误)
setScaleX(0.999636)
→ transform matrix 变化
→ 文字最终像素坐标亚像素偏移变化
→ glyph_key = (glyphID, subpixelPosition) 不匹配
→ AtlasTextOp cache MISS → 重新光栅化
错误点:
- scale 不影响 subpixel position(那是 translation 的小数部分决定的)
- 即便 subpixel position 变了,只影响个别 glyph(2-bit 量化,4 种状态),不会导致所有字形 miss
- 未提及 Strike Descriptor 层面的 cache
正确的因果链
setScaleX(0.999636)
→ CTM positionMatrix 的 scaleX 精确值变化 (每帧不同)
→ SubRun canReuse() = false [VertexFiller.cpp:98 精确比较失败]
→ SubRun 重建
→ SkStrikeSpec::MakeMask(positionMatrix) [SubRunContainer.cpp:1530]
→ MakeRecAndEffects() [SkScalerContext.cpp:1085]
→ rec.fPost2x2[0][0] = sk_relax(scaleX) [line 1106]
→ sk_relax: round(x * 1024) / 1024.0f [line 1047]
→ fPost2x2 进入 Strike Descriptor [line 1252: addEntry(kRec_SkDescriptorTag, sizeof(rec), &rec)]
→ 当 sk_relax(scale) 跳到新的 1/1024 档位
→ Strike Descriptor 与缓存中所有已有 Strike 都不匹配
→ Strike cache miss → findOrCreateStrike 创建新 Strike
→ 新 Strike 下无任何字形缓存
→ 该文本的所有 ~50 个字形都需要重新光栅化
→ AtlasTextOp.onPrepare() ~600μs (每字形 ~12μs)
原报告结论中仍然正确的部分
-
双路径缺一不可的结论仍然成立:
- 路径 1 (scale):导致 Strike key 变化 → 字形需要重光栅化
- 路径 2 (alpha/damage):驱动子树重绘 → AtlasTextOp 被执行
- 缺少路径 2:即使 Strike key 变了,如果子树不被重绘,就不会触发 AtlasTextOp
- 缺少路径 1:即使子树被重绘,如果 Strike key 不变,字形都在缓存中
-
setForceSDFT 方案的有效性和原理分析正确
-
Hardware Layer 方案的有效性分析正确
附录: 关键源码索引
| 组件 | 文件路径 | 关键行号 | 作用 |
|---|---|---|---|
| sk_relax 量化 | skia/src/core/SkScalerContext.cpp | 1046-1049 | 1/1024 精度量化 |
| scale 进入 rec | skia/src/core/SkScalerContext.cpp | 1106-1107 | fPost2x2 = sk_relax(scale) |
| Descriptor 生成 | skia/src/core/SkScalerContext.cpp | 1249-1261 | rec → Descriptor → cache key |
| MakeMask 调用 | skia/src/text/gpu/SubRunContainer.cpp | 1530-1531 | positionMatrix 传入 |
| SubRun 精确比较 | skia/src/text/gpu/VertexFiller.cpp | 98-101 | scale 精确相等才复用 |
| forceSDFT 入口 | skia/src/text/gpu/TextBlobRedrawCoordinator.cpp | 48-49 | canvas → paint |
| SDFT 判断 | skia/src/text/gpu/SubRunControl.cpp | 74, 86-87 | 绕过字号限制 |
| SDF 固定字号 | skia/src/text/gpu/SubRunControl.cpp | 131 | dfFont.setSize(dfMaskSize) |
| SDF 范围复用 | skia/src/text/gpu/SubRunControl.cpp | 145-148 | matrixInRange 范围比较 |
| promotedToLayer | hwui/RenderProperties.h | 609-658 | alpha<1 + overlapping → layer |
| AlphaFilterCanvas | hwui/pipeline/skia/RenderNodeDrawable.cpp | 639-667 | overlapping=false 时逐 op replay |
| damage 传播 | hwui/RenderNode.cpp | 665-702 | scale 变化 → 标脏 → 触发重绘 |
| View.setScaleX | frameworks/base/core/java/android/view/View.java | 20175-20186 | Java 入口 |
| View.setForceSDFT | frameworks/base/core/java/android/view/View.java | 20502-20513 | MIUI 扩展 |
| Paint JNI | hwui/jni/Paint.cpp | 1196-1199 | setForceSDFT JNI |
| RenderProxy | hwui/renderthread/RenderProxy.cpp | 804-805 | forceSDFT → RenderThread |
| CanvasContext | hwui/renderthread/CanvasContext.cpp | 1349 | 传递给 pipeline |
| Vulkan 应用 | hwui/pipeline/skia/SkiaVulkanPipeline.cpp | 108 | canvas.setForceSDFT |