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.999636mPrimitiveFields.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.999636,pivot = (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 setScaleX | mRenderNode.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 |
| drawTextBlob | positionMatrix.mapPoint(pos) 用含 scale 的 CTM 计算坐标 | ✅ 亚像素位置被动变化 |
| Glyph Atlas | SkPackedGlyphID{glyphID, mappedPos, mask} 含亚像素 | ✅ Key 每帧不同 → cache miss |
| 光栅化 | AtlasTextOp::onPrepare() 重新光栅化字形 | ✅ 每帧 ~600μs |
为什么子 View 的 Display List 不需要重建
这是理解此问题的关键认知:
RenderNode 属性动画 vs Display List 重建
Android HWUI 的设计将 View 的渲染分为两层:
- Display List(录制内容):View
onDraw()中的绘制命令(drawText、drawRect 等),只有invalidate()才会触发重新录制 - 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 回放。
完整证据链
| # | 证据 | 文件:行号 | 证明了什么 |
|---|---|---|---|
| 1 | setScaleX 在 HW 加速下只 damageInParent(),不 invalidate() | View.java:22139-22152 | 不触发子 View display list 重建 |
| 2 | RP_SET_AND_DIRTY 只设 mMatrixOrPivotDirty | RenderProperties.h:76,374 | 只影响自身 RenderNode |
| 3 | pushStagingPropertiesChanges 检查 mDirtyPropertyFields | RenderNode.cpp:678 | 子 Node 无 dirty → 跳过 |
| 4 | canvas->concat(*properties.getTransformMatrix()) | RenderNodeDrawable.cpp:729 | Scale 被合入 canvas CTM |
| 5 | displayList->draw(canvas) 在含 scale 的 canvas 上回放 | RenderNodeDrawable.cpp:693 | 子 View 绘制继承 CTM |
| 6 | positionMatrixWithRounding.mapPoint(pos) 计算最终坐标 | SubRunContainer.cpp:1238 | 亚像素位置受 CTM(含scale) 影响 |
| 7 | SkPackedGlyphID{glyphID, mappedPos, mask} 含亚像素 | SubRunContainer.cpp:1239 | Key 随 scale 变化 → cache miss |
| 8 | SDFT 路径 SkPackedGlyphID{glyphID} 不含亚像素 | SubRunContainer.cpp:1184 | SDF key 不受 scale 影响 → hit |
| 9 | Frida 验证子 View 无 invalidate/setAlpha/setScale 调用 | 参考分析文档 | 子 View 纯粹被父级连坐 |
| 10 | MiuiClock override hasOverlappingRendering()=false + hardware layer | MiuiClock.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!
结论
- ViewGroup 的
setScaleX对子 View 是纯粹的 RenderThread 级联效应,Java 层不产生任何子 View 回调 - 子 View 的 display list 不重建,只是在回放时 canvas 的 CTM 已含父级 scale
- 文字绘制的 glyph cache key 包含亚像素位置(
SkPackedGlyphID的 subPixelX/Y 字段),而亚像素位置由CTM.mapPoint(pos)决定 - 父级 scale 微变 → 子 View 文字最终坐标亚像素偏移变化 → glyph key 每帧不同 → atlas cache miss → 重新光栅化 ~600μs
- 解决方案:
setForceSDFT(true)切换到 SDF 渲染模式,glyph key 不含亚像素位置,scale 变化不影响缓存命中