ViewGroup setScaleX 对子 View 影响的完整论证

核心结论

对 ViewGroup 调用 setScaleX()子 View 在 Java 层不会收到任何回调或触发任何重建。子 View 完全是”被动”地在 RenderThread 绘制阶段通过 Canvas 矩阵级联(CTM accumulation)感受到 scale 变化。

子 View 的 display list 不变,但回放时 canvas CTM 已含父级 scale,导致文字绘制的最终坐标亚像素偏移变化 → glyph atlas cache key 不同 → AtlasTextOp cache miss → 每帧重新光栅化。


问题背景

在锁屏上划解锁场景中,KeyguardPanelViewController.updateKeyguardElementsExpansionInternal()KeyguardBottomAreaView(父 ViewGroup)执行:

// KeyguardPanelViewController.kt:4471
doScale(keyguardBottomAreaInjector.bottomView, keyguardInfoLayerScale)
 
// KeyguardPanelViewController.kt:3276-3279
private fun doScale(view: View?, scale: Float) {
    view?.let {
        it.scaleX = scale   // 例: 0.999636
        it.scaleY = scale
    }
}

View 层级:

KeyguardBottomAreaView              ← setScaleX(0.999636)
  └── LinearLayout
       ├── KeyguardIndicationTextView   ← 文字 AtlasTextOp cache miss
       └── KeyguardIndicationTextView   ← 文字 AtlasTextOp cache miss

问题:KeyguardIndicationTextView 自身没有任何属性变化,为什么每帧 AtlasTextOp 耗时 ~600μs?


完整链路分析

第一阶段:Java 层 setScaleX — 仅更新 RenderNode 属性

文件frameworks/base/core/java/android/view/View.java:20175-20187

public void setScaleX(float scaleX) {
    if (scaleX != getScaleX()) {
        scaleX = sanitizeFloatPropertyValue(scaleX, "scaleX");
        invalidateViewProperty(true, false);   // ① 前置 invalidate
        mRenderNode.setScaleX(scaleX);         // ② 更新 RenderNode 属性
        invalidateViewProperty(false, true);   // ③ 后置 invalidate
        invalidateParentIfNeededAndWasQuickRejected();
    }
}

invalidateViewProperty(true, false) — 前置 invalidate

文件View.java:22139-22154

void invalidateViewProperty(boolean invalidateParent, boolean forceRedraw) {
    if (!isHardwareAccelerated()
            || !mRenderNode.hasDisplayList()
            || (mPrivateFlags & PFLAG_DRAW_ANIMATION) != 0) {
        // 软件渲染路径(不走这里)
        if (invalidateParent) invalidateParentCaches();
        if (forceRedraw) mPrivateFlags |= PFLAG_DRAWN;
        invalidate(false);
    } else {
        // ★ 硬件加速路径 — 走这里 ★
        damageInParent();
    }
}

在硬件加速模式下,只调用 damageInParent()

// View.java:22161-22165
protected void damageInParent() {
    if (mParent != null && mAttachInfo != null) {
        mParent.onDescendantInvalidated(this, this);
    }
}

onDescendantInvalidated 只是向上传播 dirty 标记到 ViewRootImpl,告知 SurfaceFlinger 有脏区域需要重绘。

关键:这里没有设置 PFLAG_INVALIDATED 标记,不会触发任何 View(包括自身和子 View)的 display list 重建。

mRenderNode.setScaleX(scaleX) — 更新 Native RenderNode

文件frameworks/base/libs/hwui/RenderProperties.h:374

bool setScaleX(float scaleX) {
    return RP_SET_AND_DIRTY(mPrimitiveFields.mScaleX, scaleX);
}

宏展开:

// RenderProperties.h:75-76
#define RP_SET(a, b, ...) ((a) != (b) ? ((a) = (b), ##__VA_ARGS__, true) : false)
#define RP_SET_AND_DIRTY(a, b) RP_SET(a, b, mPrimitiveFields.mMatrixOrPivotDirty = true)

效果:

  • mPrimitiveFields.mScaleX = 0.999636
  • mPrimitiveFields.mMatrixOrPivotDirty = true(标记 transform matrix 需要重算)
  • 设置 mDirtyPropertyFields 标记(通过 RenderNode::setPropertyFieldsDirty

这只影响 KeyguardBottomAreaView 自身的 RenderNode。子 View 的 RenderNode 没有任何 dirty 标记。


第二阶段:RenderThread prepareTree — 属性同步

每帧绘制前,RenderThread 执行 prepareTree() 遍历整棵 RenderNode 树,同步 staging 属性到实际属性。

文件frameworks/base/libs/hwui/RenderNode.cpp:665-703

void RenderNode::pushStagingPropertiesChanges(TreeInfo& info) {
    if (mDirtyPropertyFields) {
        mDirtyPropertyFields = 0;
        damageSelf(info);                    // 标记旧 bounds 为 dirty
        info.damageAccumulator->popTransform();
        syncProperties();                    // ★ 同步属性(含新的 scaleX)
        // ...
        info.damageAccumulator->pushTransform(this);
        damageSelf(info);                    // 标记新 bounds 为 dirty
    }
}

对子 View 的影响

prepareTree 会递归遍历所有子 RenderNode:

// RenderNode.cpp:479-486
for (auto& child : mDisplayList->mChildNodes) {
    child->prepareTreeImpl(observer, info, functorsNeedLayer, (mDepth + 1));
}

但子 View 的 mDirtyPropertyFields == 0(没有属性变化),所以 pushStagingPropertiesChanges 对子 View 什么都不做。

结论:prepareTree 阶段,只有父 ViewGroup 的 RenderNode 属性被同步。子 View 无变化。


第三阶段:RenderThread 绘制 — Transform Matrix 计算

mPrimitiveFields.mMatrixOrPivotDirty == true 时,getTransformMatrix() 被调用时会重算矩阵:

文件frameworks/base/libs/hwui/RenderProperties.cpp:299-331

void RenderProperties::updateMatrix() {
    if (mPrimitiveFields.mMatrixOrPivotDirty) {
        if (!mComputedFields.mTransformMatrix) {
            mComputedFields.mTransformMatrix = new SkMatrix();
        }
        if (!mPrimitiveFields.mPivotExplicitlySet) {
            // 默认 pivot = 中心点
            mPrimitiveFields.mPivotX = mPrimitiveFields.mWidth / 2.0f;
            mPrimitiveFields.mPivotY = mPrimitiveFields.mHeight / 2.0f;
        }
        SkMatrix* transform = mComputedFields.mTransformMatrix;
        transform->reset();
        if (MathUtils::isZero(getRotationX()) && MathUtils::isZero(getRotationY())) {
            // ★ 2D 变换(常见情况)
            transform->setTranslate(getTranslationX(), getTranslationY());
            transform->preRotate(getRotation(), getPivotX(), getPivotY());
            transform->preScale(getScaleX(), getScaleY(), getPivotX(), getPivotY());
            // → 结果矩阵: Translate × Rotate × Scale(0.999636, 0.999636, cx, cy)
        } else {
            // 3D 变换路径...
        }
        mPrimitiveFields.mMatrixOrPivotDirty = false;
    }
}

对于 scaleX = scaleY = 0.999636pivot = (width/2, height/2) 的情况,生成的 SkMatrix 等效于:

| 0.999636    0         0.182 × width/2  |
| 0           0.999636  0.182 × height/2 |
| 0           0         1                |

(0.182 = 1 - 0.999636 × 2 的修正项,确保围绕中心缩放)


第四阶段:RenderThread 绘制 — Canvas CTM 级联(核心机制)

遍历到 KeyguardBottomAreaView 的 RenderNode 时:

文件frameworks/base/libs/hwui/pipeline/skia/RenderNodeDrawable.cpp:713-731

void RenderNodeDrawable::setViewProperties(const RenderNode* renderNode, SkCanvas* canvas,
                                           float* alphaMultiplier, bool ignoreLayer, bool applyClip) {
    const RenderProperties& properties = renderNode->properties();
 
    // Step A: 应用 left/top 偏移
    if (properties.getLeft() != 0 || properties.getTop() != 0) {
        canvas->translate(properties.getLeft(), properties.getTop());
    }
 
    // Step B: 应用静态/动画矩阵(如果有)
    if (properties.getStaticMatrix()) {
        canvas->concat(*properties.getStaticMatrix());
    } else if (properties.getAnimationMatrix()) {
        canvas->concat(*properties.getAnimationMatrix());
    }
 
    // Step C: ★ 应用 transform matrix(包含 scaleX/scaleY)★
    if (properties.hasTransformMatrix()) {
        if (properties.isTransformTranslateOnly()) {
            canvas->translate(properties.getTranslationX(), properties.getTranslationY());
        } else {
            canvas->concat(*properties.getTransformMatrix());
            // ★ 此刻 canvas CTM 已包含 scale(0.999636) ★
        }
    }
    // ...
}

然后回放 display list(包含子 View 的绘制命令):

// RenderNodeDrawable.cpp:693
displayList->draw(canvas);
// canvas 此时的 CTM = ... × parentTranslate × parentScale(0.999636)

display list 中包含子 View 的 RenderNodeDrawable,遍历到子 View 时:

// 子 View(KeyguardIndicationTextView)的 drawContent:
setViewProperties(childNode, canvas, ...)
  → canvas->translate(child.left, child.top)
  // 子 View 的 transformMatrix 为 identity(没有自己的 scale/rotation)
 
displayList->draw(canvas)
  // canvas CTM = ... × parentScale(0.999636) × childTranslate(left, top)
  // 此 CTM 被传递给所有绘制操作,包括 drawTextBlob

第五阶段:drawTextBlob — 亚像素位置受 Canvas CTM 影响

当 display list 回放到 drawTextBlob 操作时,Skia 需要确定每个字形的最终像素坐标:

文件skia/src/text/gpu/SubRunContainer.cpp:1216-1262(DirectMask 路径)

prepare_for_direct_mask_drawing(StrikeForGPU* strike,
                                const SkMatrix& positionMatrix,  // ← 这就是 canvas CTM!
                                SkZip<const SkGlyphID, const SkPoint> source, ...) {
    const SkIPoint mask = strike->roundingSpec().ignorePositionFieldMask;
    const SkPoint halfSampleFreq = strike->roundingSpec().halfAxisSampleFreq;
 
    // ★ 使用含父级 scale 的 matrix 来计算字形最终位置 ★
    SkMatrix positionMatrixWithRounding = positionMatrix;
    positionMatrixWithRounding.postTranslate(halfSampleFreq.x(), halfSampleFreq.y());
 
    for (auto [glyphID, pos] : source) {
        // ★ 关键:用含 scale 的 matrix 映射字形位置 ★
        const SkPoint mappedPos = positionMatrixWithRounding.mapPoint(pos);
 
        // ★ 构造包含亚像素位置的 PackedGlyphID ★
        const SkPackedGlyphID packedID{glyphID, mappedPos, mask};
        //                              ↑       ↑↑↑↑↑↑↑↑↑  ↑↑↑↑
        //                         字形ID  映射后坐标(含scale) 量化掩码
 
        // 用 packedID 查询 atlas 缓存
        switch (strike->digestFor(kDirectMask, packedID); ...) {
            case kAccept: ...  // cache hit
            case kReject: ...  // cache miss → 需要重新光栅化
        }
    }
}

SkPackedGlyphID 的构造skia/src/core/SkGlyph.h:90-91):

SkPackedGlyphID(SkGlyphID glyphID, SkPoint pt, SkIPoint mask)
    : fID{PackIDSkPoint(glyphID, pt, mask)} { }
// SkGlyph.h:164
static uint32_t PackIDSkPoint(SkGlyphID glyphID, SkPoint pt, SkIPoint mask) {
    // pt 的小数部分被量化为 2-bit 亚像素位置
    uint32_t x = (SkScalarAs2sCompliment(pt.x()) >> 1 & kSubPixelPosMask) << kSubPixelX;
    uint32_t y = (SkScalarAs2sCompliment(pt.y()) >> 1 & kSubPixelPosMask) << kSubPixelY;
    // 与 mask 做 AND(mask 决定哪些方向使用亚像素定位)
    x &= mask.x();
    y &= mask.y();
    return (glyphID << kGlyphID) | x | y;
}

最终 glyph key = (glyphID << 8) | (subPixelX << 4) | (subPixelY << 2)


第六阶段:Scale 变化导致 Cache Miss 的数学证明

假设一个字形原始位置 pos = (100.0, 200.0)

帧 N:scale = 1.0(无动画)

CTM = Identity
mappedPos = (100.0, 200.0)
subPixelX = quantize(frac(100.0)) = quantize(0.0) = 0b00
subPixelY = quantize(frac(200.0)) = quantize(0.0) = 0b00
packedID = (glyphID << 8) | (0 << 4) | (0 << 2)

帧 N+1:scale = 0.999636,pivot = (836, 1182)

CTM 含 scale:
mappedPos.x = (100.0 - 836) × 0.999636 + 836 = 100.0 - 736 × 0.000364 = 99.732
mappedPos.y = (200.0 - 1182) × 0.999636 + 1182 = 200.0 - 982 × 0.000364 = 199.642

subPixelX = quantize(frac(99.732)) = quantize(0.732) = 0b11  (≈ 0.75 桶)
subPixelY = quantize(frac(199.642)) = quantize(0.642) = 0b10 (≈ 0.5 桶)
packedID = (glyphID << 8) | (3 << 4) | (2 << 2)   ← ★ 与帧 N 不同!

帧 N+2:scale = 0.999272

mappedPos.x = (100.0 - 836) × 0.999272 + 836 = 100.0 - 736 × 0.000728 = 99.464
subPixelX = quantize(frac(99.464)) = quantize(0.464) = 0b01  (≈ 0.25 桶)
packedID = (glyphID << 8) | (1 << 4) | ...         ← ★ 又不同!

每帧 scale 微变 → 文字最终坐标小数部分(亚像素偏移)变化 → 2-bit 量化后落入不同桶 → SkPackedGlyphID 不同 → atlas cache miss → 重新光栅化。


各层面汇总

阶段发生了什么子 View 响应
Java setScaleXmRenderNode.setScaleX(0.999636) + damageInParent()❌ 不触发子 View invalidate
RenderNode 属性父 Node: mDirtyPropertyFields 置位 + mMatrixOrPivotDirty=true❌ 子 Node 无 dirty 标记
prepareTree父 Node: syncProperties() 同步新 scaleX❌ 子 Node 跳过(无 dirty)
Display List父和子的 display list 都不重建❌ 无 re-record
绘制遍历canvas->concat(transformMatrix) 将 scale 合入 CTM✅ 子 View 绘制自动继承 CTM
drawTextBlobpositionMatrix.mapPoint(pos) 用含 scale 的 CTM 计算坐标✅ 亚像素位置被动变化
Glyph AtlasSkPackedGlyphID{glyphID, mappedPos, mask} 含亚像素✅ Key 每帧不同 → cache miss
光栅化AtlasTextOp::onPrepare() 重新光栅化字形✅ 每帧 ~600μs

为什么子 View 的 Display List 不需要重建

这是理解此问题的关键认知:

RenderNode 属性动画 vs Display List 重建

Android HWUI 的设计将 View 的渲染分为两层:

  1. Display List(录制内容):View onDraw() 中的绘制命令(drawText、drawRect 等),只有 invalidate() 才会触发重新录制
  2. RenderNode Properties(变换属性):scale、alpha、translation、rotation 等,可以独立于 display list 变化
┌─────────────────────────────────────────────┐
│ RenderNode (KeyguardBottomAreaView)          │
│                                              │
│ Properties:                                  │
│   scaleX = 0.999636  ← 每帧变化             │
│   scaleY = 0.999636                          │
│   alpha = 0.994926                           │
│                                              │
│ DisplayList: [不变,不重建]                    │
│   ├── translate(0, 0)                        │
│   ├── drawRenderNode(LinearLayout)           │
│   │     └── drawRenderNode(IndicationTV)     │
│   │           └── DisplayList: [不变]        │
│   │                 └── drawTextBlob(...)     │
│   └── ...                                    │
└─────────────────────────────────────────────┘

setScaleX 修改的是 Properties 层,display list 层完全不动。子 View 的 display list 更是完全不受影响——它的 drawTextBlob 命令早在 invalidate() 时就已经录制好了,后续只是被回放(replay)。

回放时 Canvas 状态才是关键

Display List 的回放是在一个已经被父级 transform 修改过的 canvas 上进行的:

DisplayListData::draw(canvas):
  遍历录制的每条命令,在 canvas 上执行:
    - drawTextBlob(blob, x, y, paint) → canvas 内部用 CTM 变换坐标

此时 canvas 的 CTM 已经是 ... × parentScale(0.999636) × childOffset 的累积结果。所以虽然 drawTextBlob 的参数(blob, x, y)从未改变,但 Skia 在决定字形具体像素位置时会乘以 CTM,得到不同的最终坐标。


与 setForceSDFT 的关系

理解了上述机制,就能理解为什么 setForceSDFT 能解决问题:

DirectMask(默认):
  canvas CTM 含 scale → mapPoint(pos) 得到新坐标 → 亚像素偏移变化
  → SkPackedGlyphID{glyphID, mappedPos, mask} → key 含亚像素 → MISS

SDFT(setForceSDFT=true):
  canvas CTM 含 scale → mapPoint(pos) 得到新坐标 → 亚像素偏移变化(同样发生)
  → 但 SkPackedGlyphID{glyphID} → key 不含亚像素 → HIT!
  → 位置差异由 GPU vertex shader 在绘制时处理(SDF 纹理不依赖位置)

对比验证:Hardware Layer 为什么有效

如果给 KeyguardBottomAreaView 设置 LAYER_TYPE_HARDWARE

RenderNodeDrawable::drawContent() 中:
  if (renderNode->getLayerSurface() && mComposeLayer) {
      // 合成 hardware layer:
      // layer 内容(子 View 的绘制结果)已经缓存为 GPU 纹理
      // 只对纹理做 scale/alpha 变换,不重放 display list
      → 子 View 的 drawTextBlob 不执行
      → AtlasTextOp 不触发
  }

Hardware Layer 将子树的渲染结果”冻结”为纹理,父级的 scale 变化只是对纹理做 GPU 变换,完全绕过了 display list 回放。


完整证据链

#证据文件:行号证明了什么
1setScaleX 在 HW 加速下只 damageInParent(),不 invalidate()View.java:22139-22152不触发子 View display list 重建
2RP_SET_AND_DIRTY 只设 mMatrixOrPivotDirtyRenderProperties.h:76,374只影响自身 RenderNode
3pushStagingPropertiesChanges 检查 mDirtyPropertyFieldsRenderNode.cpp:678子 Node 无 dirty → 跳过
4canvas->concat(*properties.getTransformMatrix())RenderNodeDrawable.cpp:729Scale 被合入 canvas CTM
5displayList->draw(canvas) 在含 scale 的 canvas 上回放RenderNodeDrawable.cpp:693子 View 绘制继承 CTM
6positionMatrixWithRounding.mapPoint(pos) 计算最终坐标SubRunContainer.cpp:1238亚像素位置受 CTM(含scale) 影响
7SkPackedGlyphID{glyphID, mappedPos, mask} 含亚像素SubRunContainer.cpp:1239Key 随 scale 变化 → cache miss
8SDFT 路径 SkPackedGlyphID{glyphID} 不含亚像素SubRunContainer.cpp:1184SDF key 不受 scale 影响 → hit
9Frida 验证子 View 无 invalidate/setAlpha/setScale 调用参考分析文档子 View 纯粹被父级连坐
10MiuiClock override hasOverlappingRendering()=false + hardware layerMiuiClock.java:343正确做法的对比参考

示意图

                    Java 层                          RenderThread
                 ═══════════                    ══════════════════════

setScaleX(0.999636)
  │
  ├→ mRenderNode.setScaleX(0.999636)
  │     设置: mScaleX = 0.999636
  │           mMatrixOrPivotDirty = true
  │           mDirtyPropertyFields |= SCALE
  │
  └→ damageInParent()
       → 标记脏区域到 ViewRootImpl
       → 触发下一帧 draw
                                                prepareTree:
                                                  遍历 RenderNode 树
                                                  │
                                                  ├─ 父 Node (BottomAreaView):
                                                  │   mDirtyPropertyFields != 0
                                                  │   → syncProperties()
                                                  │   → updateMatrix(): 重算 transformMatrix
                                                  │       scale(0.999636, 0.999636, cx, cy)
                                                  │
                                                  └─ 子 Node (IndicationTextView):
                                                      mDirtyPropertyFields == 0
                                                      → 跳过,什么都不做

                                                draw:
                                                  遍历 RenderNode 树绘制
                                                  │
                                                  ├─ 绘制父 Node:
                                                  │   setViewProperties(parent, canvas):
                                                  │     canvas->concat(transformMatrix)
                                                  │     // CTM 现在含 scale(0.999636)
                                                  │
                                                  │   displayList->draw(canvas):
                                                  │     // 回放父的 display list
                                                  │     // 遇到子 RenderNodeDrawable
                                                  │
                                                  └─── 绘制子 Node:
                                                        setViewProperties(child, canvas):
                                                          canvas->translate(left, top)
                                                          // 子 View 无 transform(identity)

                                                        displayList->draw(canvas):
                                                          // 回放子的 display list
                                                          // 包含 drawTextBlob(blob, x, y, paint)
                                                          //
                                                          // ★ 此时 canvas CTM 含父级 scale ★
                                                          // Skia 内部:
                                                          //   finalPos = CTM × (x, y)
                                                          //   subPixel = quantize(frac(finalPos))
                                                          //   key = (glyphID, subPixel)
                                                          //   → 每帧 subPixel 不同 → MISS!

结论

  1. ViewGroup 的 setScaleX 对子 View 是纯粹的 RenderThread 级联效应,Java 层不产生任何子 View 回调
  2. 子 View 的 display list 不重建,只是在回放时 canvas 的 CTM 已含父级 scale
  3. 文字绘制的 glyph cache key 包含亚像素位置SkPackedGlyphID 的 subPixelX/Y 字段),而亚像素位置由 CTM.mapPoint(pos) 决定
  4. 父级 scale 微变 → 子 View 文字最终坐标亚像素偏移变化 → glyph key 每帧不同 → atlas cache miss → 重新光栅化 ~600μs
  5. 解决方案setForceSDFT(true) 切换到 SDF 渲染模式,glyph key 不含亚像素位置,scale 变化不影响缓存命中