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 positionCTM 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.0
  • sk_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) → RenderNode promotedToLayer → 子树每帧被遍历重绘

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<1promotedToLayer → 路径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 < 1promotedToLayer() → 自动提升为 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/YSCALE_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 时,绕过 fMinDistanceFieldFontSizefMaxDistanceFieldFontSize 限制,强制走 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 模式下:

  1. 字体 size 被固定为预定义值(32/72/162px)→ fPost2x2 中的 scale 不再影响 Strike key
  2. setSubpixel(false)SkPackedGlyphID 不含 subpixel position → glyph key 只有 glyphID
  3. 实际缩放在 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 → 重新光栅化

错误点

  1. scale 不影响 subpixel position(那是 translation 的小数部分决定的)
  2. 即便 subpixel position 变了,只影响个别 glyph(2-bit 量化,4 种状态),不会导致所有字形 miss
  3. 未提及 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. 双路径缺一不可的结论仍然成立:

    • 路径 1 (scale):导致 Strike key 变化 → 字形需要重光栅化
    • 路径 2 (alpha/damage):驱动子树重绘 → AtlasTextOp 被执行
    • 缺少路径 2:即使 Strike key 变了,如果子树不被重绘,就不会触发 AtlasTextOp
    • 缺少路径 1:即使子树被重绘,如果 Strike key 不变,字形都在缓存中
  2. setForceSDFT 方案的有效性和原理分析正确

  3. Hardware Layer 方案的有效性分析正确


附录: 关键源码索引

组件文件路径关键行号作用
sk_relax 量化skia/src/core/SkScalerContext.cpp1046-10491/1024 精度量化
scale 进入 recskia/src/core/SkScalerContext.cpp1106-1107fPost2x2 = sk_relax(scale)
Descriptor 生成skia/src/core/SkScalerContext.cpp1249-1261rec → Descriptor → cache key
MakeMask 调用skia/src/text/gpu/SubRunContainer.cpp1530-1531positionMatrix 传入
SubRun 精确比较skia/src/text/gpu/VertexFiller.cpp98-101scale 精确相等才复用
forceSDFT 入口skia/src/text/gpu/TextBlobRedrawCoordinator.cpp48-49canvas → paint
SDFT 判断skia/src/text/gpu/SubRunControl.cpp74, 86-87绕过字号限制
SDF 固定字号skia/src/text/gpu/SubRunControl.cpp131dfFont.setSize(dfMaskSize)
SDF 范围复用skia/src/text/gpu/SubRunControl.cpp145-148matrixInRange 范围比较
promotedToLayerhwui/RenderProperties.h609-658alpha<1 + overlapping → layer
AlphaFilterCanvashwui/pipeline/skia/RenderNodeDrawable.cpp639-667overlapping=false 时逐 op replay
damage 传播hwui/RenderNode.cpp665-702scale 变化 → 标脏 → 触发重绘
View.setScaleXframeworks/base/core/java/android/view/View.java20175-20186Java 入口
View.setForceSDFTframeworks/base/core/java/android/view/View.java20502-20513MIUI 扩展
Paint JNIhwui/jni/Paint.cpp1196-1199setForceSDFT JNI
RenderProxyhwui/renderthread/RenderProxy.cpp804-805forceSDFT → RenderThread
CanvasContexthwui/renderthread/CanvasContext.cpp1349传递给 pipeline
Vulkan 应用hwui/pipeline/skia/SkiaVulkanPipeline.cpp108canvas.setForceSDFT