非联动亮灭屏丢帧分析:KeyguardClockContainer alpha 残留导致全屏离屏渲染

问题现象

锁屏亮屏场景,Perfetto trace 中观察到 RenderThread 的 GPU 耗时偏长

  • waiting for GPU completion 平均 5.10ms,峰值 10.55ms
  • DrawFrames 峰值 7.21ms,逼近 120Hz 的 8.33ms deadline
  • Trace 中 alpha caused saveLayer 1080x2400 出现 167 次(几乎每帧 2 次全屏离屏渲染)

哪个 View 触发了离屏

Trace 中搜索 alpha caused saveLayer,发现 169 次全屏离屏渲染,来源:

ViewsaveLayer 次数尺寸内容
KeyguardClockContainer85 次1080x2400AllInOneHourClock(小时+高斯模糊)
FrameLayout (SecondaryClockContainer)84 次1080x2400AllInOneMinuteClock(分钟+日期)

两个容器都是 match_parent(1080x2400 全屏),但实际时钟内容只有 1080x1348。分配 2 个 10.1MB 全屏离屏 buffer,加上模糊效果的 3 个中间 target(~5.8MB × 3),单帧显存分配约 38MB。

单帧 GPU 开销拆解(DrawFrames 24446)

allocateImageMemory 10 次:
├── [1-4] 小纹理命中缓存(~0ms)
├── [5] saveLayer buffer — KeyguardClockContainer 1080x2400  → 0.619ms
├── [6] saveLayer buffer — SecondaryClockContainer 1080x2400 → 0.832ms
├── [7] 小纹理(~0ms)
├── [8] 高斯模糊中间 target (generateLMGaussianBlur)       → 0.462ms
├── [9] 高斯模糊中间 target (drawMiGlassCustom)            → 0.482ms
└── [10] 高斯模糊中间 target (drawMiBlurBlendCustom)       → 0.802ms

大块分配总耗时: 3.2ms(占 DrawFrames 6.44ms 的 50%)

saveLayer 不仅自身分配离屏 buffer,还导致模糊效果缓存失效 → 连锁触发 3 次模糊 target 重新分配。

为什么会离屏

触发条件

HWUI 在 View.draw() 中的判断逻辑:

if (alpha < 1.0f && hasOverlappingRendering() == true)
    → canvas.saveLayerAlpha(bounds, alpha)  // 创建离屏 buffer

为什么 alpha < 1.0

亮屏时 KeyguardClockInjector.startWakeupAnim() 对两个容器执行了 startAnimation

// KeyguardClockInjector.kt 第 249-259 行
fun startWakeupAnim(translationY: Float, listener: AnimationListener?) {
    val mainContainer = getView()           // KeyguardClockContainer
    val secondContainer = getSecondaryClockLayerView()  // FrameLayout
 
    mainContainer?.startAnimation(generalWakeupTranslateAnimation(translationY, listener))
    secondContainer?.startAnimation(generalWakeupTranslateAnimation(translationY))
}

动画使用 AlphaAnimation(0f, 1f) + SpringInterpolator(0.95, 0.8571)

关键startAnimation 使用旧版 Animation 框架,alpha 通过 Transformation 临时注入,不经过 View.setAlpha()。每帧 View.draw() 时从 Transformation 取出 alpha 值,作为局部变量传给 saveLayerAlpha(),用完即弃,不写回 View。因此 View.getAlpha() 始终返回 1.0,但渲染时实际 alpha < 1.0。

为什么整个动画过程都触发(而不只是最后几帧)

AlphaAnimation(0f, 1f) 意味着动画从 alpha=0 到 alpha≈1,全程 alpha < 1.0,每帧都满足 saveLayer 触发条件。Frida Hook 验证:

[ClockAlpha] AlphaAnimation alpha=0.9522857070
[ClockAlpha] AlphaAnimation alpha=0.9560980797
...
[ClockAlpha] AlphaAnimation alpha=0.9947436452
(永远到不了精确 1.0 — SpringInterpolator 数学特性)

完整调用链

亮屏 → KeyguardPanelViewController.onDisplayStateOn()
  → KeyguardClockInjector.startWakeupAnim()
    → container.startAnimation(AnimationSet[AlphaAnimation + TranslateAnimation])

每帧 Choreographer 回调 → View.draw(canvas, parent, drawingTime):
  ① applyLegacyAnimation()
     → Animation.getTransformation(time, transformation)
       → AlphaAnimation.applyTransformation(interpolatedTime, t)
         → SpringInterpolator.getInterpolation(input)
           = e^(r*input) * (c1*cos + c2*sin) + 1.0 = 0.95~0.9999(≠ 1.0)
         → t.setAlpha(interpolatedTime) → 0.9523

  ② float alpha = getAlpha()            → 1.0(View 自身属性)
     float transformAlpha = t.getAlpha() → 0.9523(Animation 临时值)
     alpha *= transformAlpha             → 0.9523(局部变量)

  ③ if (alpha < 1.0f)                   → true
       if (hasOverlappingRendering())    → true(默认)
         canvas.saveLayerAlpha(0, 0, 1080, 2400, 242)
           → 分配 10.1MB 离屏 buffer
           → 模糊缓存失效 → 重新分配 3 个模糊 target

  ④ dispatchDraw() → 子 View 画到离屏 buffer
  ⑤ canvas.restore() → 离屏 buffer 以 alpha 合成回主 buffer

优化方案

方案一:hasOverlappingRendering = false

// KeyguardClockContainer.kt
override fun hasOverlappingRendering(): Boolean = false
 
// KeyguardClockContainer.kt onPanelViewAttachedToWindow 中
mKeyguardSecondaryClockContainer?.setHasOverlappingRendering(false)

原理View.draw() 中,当 Animation alpha < 1.0 时的判断逻辑:

if (alpha < 1.0f) {
    if (hasOverlappingRendering()) {
        → canvas.saveLayerAlpha(bounds, alpha)  // 创建离屏 buffer,保证重叠区域透明度正确
    } else {
        → 逐子 View 直接乘 alpha 绘制(无离屏)
    }
}

返回 false 告诉框架”子 View 无视觉重叠,不需要离屏保证透明度一致性”。

Frida 验证结果

指标修复前修复后
saveLayer 次数1670
GPU completion 平均5.10ms~1.9ms (-63%)
GPU completion 最大10.55ms~5.4ms (-49%)

风险评估

“重叠”是指两个子 View 在同一像素位置都有非透明内容。如果 hasOverlappingRendering=false 但实际有重叠,重叠区域会被叠加两次 alpha,视觉上比预期偏暗。

KeyguardClockContainer 子 View 结构(renderFrame 确认):

KeyguardClockContainer (1080x2400)
└── AllInOneHourClock (1080x1348)
    ├── FrameLayout — 高斯模糊背景 (generateLMGaussianBlur)
    └── ConstraintLayout — 时间数字 (TimeView + drawMiGlassCustom 毛玻璃)

文字叠加在模糊背景上 → 存在重叠。当 alpha < 1.0 时:

  • 有 saveLayer(正确):A 和 B 正常混合后整体 × alpha
  • 无 saveLayer:A × alpha 先画,B × alpha 覆盖 → 如果 B 有半透明(毛玻璃),模糊背景颜色会”透”过来更多
风险场景alpha 状态持续时间视觉影响
亮屏动画0→1(700ms)短暂动态动画过程中难以察觉
setMagazineAlpha 画报切换0~1可能数百ms如果长时间中间 alpha,可能有可感知差异
checkUpdateClockAlpha 遮挡过渡0~1短暂风险低
未来功能设置中间 alpha未知未知不可预见

方案二:缩小尺寸 + 缩短时间 + 终值校正(组合优化)

不改 hasOverlappingRendering,通过减少离屏帧数和尺寸来降低 GPU 开销。

2a. 动画下移到子 View(缩小离屏尺寸)

当前动画在 KeyguardClockContainer(1080×2400)上,但实际内容 AllInOneHourClock 只有 1080×1348。把 startAnimation 下移到子 View:

// KeyguardClockInjector.kt - startWakeupAnim
override fun startWakeupAnim(translationY: Float, listener: AnimationListener?) {
    val mainContainer = getView()
    val secondContainer = getSecondaryClockLayerView()
 
    // 动画下移到实际内容 View
    val hourClock = mainContainer?.getChildAt(0)   // AllInOneHourClock 1080x1348
    val minuteClock = secondContainer?.getChildAt(0) // AllInOneMinuteClock
 
    hourClock?.startAnimation(generalWakeupTranslateAnimation(translationY, wakeupAnimListener))
    minuteClock?.startAnimation(generalWakeupTranslateAnimation(translationY))
}

效果:saveLayer 从 1080×2400 (10.1MB) → 1080×1348 (5.7MB),GPU 像素处理量 -44%

注意:TranslateAnimation 的偏移量需根据子 View 坐标系调整。

2b. 缩短动画 duration(减少离屏帧数)

// MiuiAnimationUtils.kt
set.duration = 500  // 700 → 500(缩短 30%)

离屏帧数从 ~84帧降到 ~60帧。需 UX 确认动画流畅性。

2c. SpringInterpolator 阈值截断 + 终值校正

SpringInterpolator 的特性是接近终值时变化极慢(最后 200ms 从 0.99 到 0.9999),视觉上无法分辨但仍触发全屏离屏。截断尾部:

// SpringInterpolator.java
public float getInterpolation(float input) {
    if (input >= 1.0f) return 1.0f;  // 终值校正
    float result = (float)(
        Math.pow(Math.E, r * input) * (c1 * Math.cos(w * input) + c2 * Math.sin(w * input)) + 1.0f);
    if (result > 0.99f) return 1.0f;  // 阈值截断:alpha > 0.99 直接归 1
    return result;
}

效果:最后 ~150ms 的帧不再触发 saveLayer,肉眼无法分辨 0.99 和 1.0 的差异。

方案二组合效果

优化手段效果风险
动画下移到子 ViewsaveLayer 尺寸 -44%TranslateY 需调整坐标系
duration 700→500ms离屏帧数 -30%UX 确认动画节奏
阈值截断 >0.99→1.0尾部 ~150ms 不再离屏尾部可能有轻微突变
终值校正 input≥1→1.0消除动画结束后残留
原始:    84帧 × 1080×2400 × 2容器 = 每帧 20.2MB 离屏
优化后:  ~35帧 × 1080×1348 × 2容器 = 每帧 11.4MB 离屏

GPU 总离屏负载: 1697MB → 399MB(-76%)

两大方案对比

方案一 hasOverlappingRendering=false方案二 组合优化
saveLayer完全消除减少 76%
GPU 开销降低-63%~-50%
改动量2 行~15 行
视觉风险毛玻璃重叠区域可能偏暗动画略快 + 尾部截断
适用场景子 View 无半透明重叠或可接受差异方案一风险不可接受时的退路

推荐:优先尝试方案一(改动最小、效果最好)。如果 UX 评审认为毛玻璃效果差异不可接受,退而使用方案二组合优化。两者也可叠加使用——方案一消除离屏 + 方案二的终值校正防御残留。