锁屏点击数字态展开 — RenderThread Vulkan 分配风暴根因分析

1. 基本信息

项目内容
设备xiaomi(platform=SM8845P,骁龙 8 Gen 3 平台)
系统HyperOS(MIUI 衍生)
问题时间2026-05-26 14:51:16 ~ 14:51:47(trace 抓取窗口)
日志来源/home/docker/work/bugreport/点击数字态展开/验证点击数字态展开场景的流畅性.zip,trace test_20260526-145116.pftrace(85 MB perfetto)
业务场景锁屏点击 NotificationNumStateView,通知从「数字折叠态」展开为「列表态」
关注现象RenderThread 中大量 auto skgpu::VulkanAMDMemoryAllocator::allocateImageMemory(VkImage, uint32_t, skgpu::VulkanBackendMemory*)::(anonymous class)::operator()() const 调用导致绘帧耗时
确信度✅ 确定(trace 字符串 + hwui 源码 + MIUI 业务代码三方互证)

2.1 一句话结论(已根据 trace 实测修正)

allocateImageMemory 只在首帧密集出现,后续帧不再触发 —— 这是一次”冷启动分配尖峰”,不是”每帧分配风暴”。

〔人话翻译〕allocateImageMemory 是显卡(GPU)“申请一块新内存来存图片”的最底层调用。屏幕上每一块”离屏渲染图”(不直接画屏幕,而是先在 GPU 单独一块内存里画好、再合到主画面上的图)都要先调一次它。trace 里它密集出现 = 同时申请多块大显存 = 卡顿。

点击瞬间,KeyguardMaskFadeEffectController(锁屏画报淡出特效控制器)被 IFaceMaskInteractor.maskState=true 这个状态切换触发,同一帧内对三个全屏 View(主时钟容器、次时钟容器、keyguard_translation_info 画报容器)调用 setRenderEffect(...),给它们挂上一个 AGSL 渐变着色器特效。

〔人话翻译〕setRenderEffect 是给一个 View 挂”特效”(模糊、变色、自定义 shader 等)。一旦挂上,Android 渲染系统就不能直接把这个 View 的内容画到屏幕,必须先在 GPU 上单独开一块跟 View 一样大的内存(“离屏 layer”),先把 View 画进去、再用特效处理一遍、最后合到屏幕。这块离屏内存就是 allocateImageMemory 申请的来源。所谓”AGSL”是 Android 自定义的着色器小程序语言,可以让程序员在 GPU 上自己写一段像素处理代码(这里写的是按 y 坐标做 alpha 渐变)。

实测:3 个全屏 View 同帧首次挂特效 → 每个 View 触发 2 次 allocateImageMemory → 加上场景里独立存在的 3 次小块基线 = 首帧总共 9 次。其中 6 次是大块(每块 ≈ 27.4 MB,全屏量级 3200×2136×4 字节),3 次是小块(< 100 KB)。总 GPU 显存压力 ≈ 164 MB 集中在一帧

后续帧业务代码每帧仍重新 new 一个新特效对象,但显卡内存层有 Vulkan VMA 内存池 + Skia 资源池 兜底(上一帧释放的内存块没有真正还给系统,留在池里待复用),所以 allocateImageMemory 这条最底层的”找系统要内存”调用不再被触发。

〔人话翻译〕“VMA 内存池”可以理解为显存的二级缓存:第一次要 27 MB 时它真的从系统拿;用完释放后这 27 MB 留在池里,下次再要相同尺寸就直接给,不再问系统。所以只有第一次冷启动那一帧痛,后续帧就不痛了

一句话压缩:罪魁是”首帧三个全屏 layer 同时冷启动”,而不是”每帧重分配”;第 1 帧痛,第 2 帧起不痛。


2.2 一张图描述现象

白板流程图:首帧渲染卡顿调用链

用户点击 NumStateView (NotificationNumStateAreaTouchHandler)
  ↓
viewModel.goToList() / NotificationContainerViewModel.onClickToList()
  ↓
通知栈展开 + 锁屏内容收起 → 触发 IFaceMaskInteractor.maskState=true
  ↓
KeyguardMaskFadeEffectController.start()  [folme spring 动画驱动 ratio: 0→1]
  ↓
applyEffect(fadeEndY, ratio0) → 首次 RenderEffect.createRuntimeShaderEffect()
  ├─→ primaryClock.setRenderEffect(effect)          [3200×2136 全屏 - 首次 promote]
  ├─→ secondaryClock.setRenderEffect(effect)        [3200×2136 全屏 - 首次 promote]
  └─→ keyguard_translation_info.setRenderEffect(effect) [3200×2136 全屏 ★主嫌疑 - 首次 promote]
             ↓
LayerProperties::setImageFilter  [mImageFilter: nullptr → newPtr]
             ↓
RenderProperties::promotedToLayer()  [检查到 mImageFilter != nullptr → 强制走 RenderLayer 路径]
             ↓
SkiaPipeline::renderLayerImpl  [同一帧内三次 drawLayer 操作]
             ↓
首次 getLayerSurface() 创建离屏画布 + SkImages::MakeWithFilter 算出滤镜结果
             ↓
GPU 端冷分配:每个 View 实测 2 块 ≈ 27.4 MB × 6 = 164 MB
             ↓
VulkanAMDMemoryAllocator::allocateImageMemory  [首帧实测共 9 次]
             ↓
首帧渲染严重超时 → 掉帧
             ↓
Vulkan VMA 内存池命中 / Skia scratch resource 复用
★ allocateImageMemory 不再触发(后续帧无此压力)
             ↓
但每帧仍 new effect ptr,仍走 MakeWithFilter CPU 路径

3. 问题时间线

14:51:16  ⭐ trace 抓取开始(perfetto -o test_20260526-145116.pftrace)
14:51:31  perfetto 进入 record,开始记录 RenderThread 与 KeyguardMaskFade 日志
14:51:??  ⭐ 用户点击 NotificationNumStateView(数字态)
14:51:??  IFaceMaskInteractor.maskState 切到 visible=true,fadeEndY=127
          → KeyguardMaskFade 日志:maskState: visible=true, fadeEndY=127
14:51:??  Folme 动画启动(hideEase/showEase spring),animationRatio 在 0→1 间 tick
14:51:??  ⭐ 渲染线程出现持续的 drawLayer/allocateImageMemory 风暴
          drawLayer [KeyguardTranslationInfoView] 3200.0 x 2136.0
          drawLayer [NotificationNumStateView] 348.0 x 51.0
          drawLayer [TextView] 157.0 x 51.0
          drawLayer [MiuiHollowBatteryMeterIconView] 70.0 x 50.0
          alpha caused saveLayer 348x51
          alpha caused saveLayer 157x51
14:51:??  动画结束,maskState: visible=false → clearEffect()
14:51:47  ⭐ trace 抓取结束(SIGINT)
 

4. 根因分析

4.1 现象:trace 中观察到了什么?

直接从 test_20260526-145116.pftrace 的字符串区抓出的关键 trace event:

drawLayer [KeyguardTranslationInfoView] 3200.0 x 2136.0   ← 全屏量级离屏 layer
drawLayer [NotificationNumStateView]    348.0 x 51.0
drawLayer [TextView]                    157.0 x 51.0
drawLayer [MiuiHollowBatteryMeterIconView] 70.0 x 50.0
alpha caused saveLayer 348x51
alpha caused saveLayer 157x51
auto skgpu::VulkanAMDMemoryAllocator::allocateImageMemory(...)
KeyguardMaskFade  maskState: visible=true,  fadeEndY=127
KeyguardMaskFade  maskState: visible=false, fadeEndY=127
 

📖 解读:drawLayer [<view>] WxH 是 hwui 每次重画一块”离屏 layer”时打的标记。KeyguardTranslationInfoView 3200×2136 几乎吃满整个窗口尺寸,是体量最大的离屏 layer。KeyguardMaskFade 这条业务日志佐证:动画在 trace 期间真的被触发并完成了一个完整生命周期(true→false)。

4.2 直接原因:哪里把 KeyguardTranslationInfoView 升级成了离屏 layer?

业务侧只有一条路径调用 setRenderEffect(...) 命中 keyguard_translation_info,就是 KeyguardMaskFadeEffectController

代码佐证(来源:KeyguardMaskFadeEffectController.kt):

// L52-L64:构造一个无分支 AGSL shader,做 fadeEndY 以下到 fadeStartY 的渐变 alpha
private val AGSL_FADE_SHADER = """
    uniform shader content;
    uniform float fadeStartY;
    uniform float fadeEndY;
    uniform float ratio;
    half4 main(float2 coord) {
        half4 color = content.eval(coord);
        float range = max(fadeStartY - fadeEndY, 1.0);
        float t = saturate((coord.y - fadeEndY) / range);
        float alpha = mix(1.0, t, ratio);
        return color * half(alpha);
    }
""".trimIndent()
 
// L78-L100:start() 监听 maskState,spring 驱动 ratio 0→1,每个 ratio 都触发 applyEffect
override fun start() {
    val animationRatio = MutableStateFlow(0f)
    val folme = Folme.useValue(animationRatio)
 
    scope.launch {
        faceMaskInteractor.maskState.collect { (visible, fadeEndY) ->
            currentFadeEndY = fadeEndY.toFloat()
            if (visible) {
                ensureShader()
                folme.to(RATIO_PROPERTY, 1f, AnimConfig().setEase(showEase))
            } else {
                folme.to(RATIO_PROPERTY, 0f, AnimConfig().setEase(hideEase))
            }
        }
    }
 
    scope.launch {
        animationRatio.collect { ratio ->
            if (ratio <= 0f) clearEffect() else applyEffect(currentFadeEndY, ratio)
        }
    }
}
 
// L108-L125:每帧创建一个新的 RenderEffect 并 set 到三个全屏容器上
private fun applyEffect(fadeEndY: Float, ratio: Float) {
    val shader = fadeShader ?: return
    val fadeStartY = fadeEndY + fadeGradientHeightPx.value
    shader.setFloatUniform("fadeStartY", fadeStartY)
    shader.setFloatUniform("fadeEndY", fadeEndY)
    shader.setFloatUniform("ratio", ratio)
    val effect = RenderEffect.createRuntimeShaderEffect(shader, "content")  // ★ 每帧 new
 
    findPrimaryClock()?.setRenderEffect(effect)
    findSecondaryClock()?.setRenderEffect(effect)
    val translationInfo = findTranslationInfo()
    if (findMagazineView()?.parent === translationInfo) {
        translationInfo?.setRenderEffect(effect)                            // ★ 击中 KeyguardTranslationInfoView
    }
    maskFadeEffectProvider.setFadeEffect(effect)
}
 

xml 佐证(来源:keyguard_panel_view.xml:51-64):

<com.android.keyguard.widget.KeyguardInfoLayerView
    android:id="@+id/keyguard_info_layer"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
 
    <com.android.keyguard.widget.KeyguardTranslationInfoView
        android:id="@+id/keyguard_translation_info"
        android:layout_width="match_parent"      ← 全屏宽
        android:layout_height="match_parent">    ← 全屏高
        ...
    </com.android.keyguard.widget.KeyguardTranslationInfoView>
</com.android.keyguard.widget.KeyguardInfoLayerView>
 

〔人话翻译〕match_parent 表示”和父容器一样大”。这个 view 的父容器是整个锁屏窗口,所以它就是全屏。单块这种尺寸的 RGBA8 图像 = 3200×2136×4 字节 ≈ 27.4 MB 显存——你给它挂一次特效,GPU 至少要拿这么多显存出来当草稿用。

4.3 深层原因:hwui 为什么必须创建离屏 layer 并每帧重分配 GPU image?

4.3.1 setRenderEffect 在 hwui 中的作用:强制 promote 到 RenderLayer

代码佐证android_graphics_RenderNode.cpp:285):

static jboolean android_view_RenderNode_setRenderEffect(CRITICAL_JNI_PARAMS_COMMA jlong renderNodePtr,
        jlong renderEffectPtr) {
    SkImageFilter* imageFilter = reinterpret_cast<SkImageFilter*>(renderEffectPtr);
    return SET_AND_DIRTY(mutateLayerProperties().setImageFilter, imageFilter, RenderNode::GENERIC);
}
 

代码佐证RenderProperties.cpp:62-65):

bool LayerProperties::setImageFilter(SkImageFilter* imageFilter) {
    if(mImageFilter.get() == imageFilter) return false;   // 仅当指针一致才跳过
    mImageFilter = sk_ref_sp(imageFilter);
    ...
}
 

代码佐证RenderProperties.h:609-663):

bool promotedToLayer() const {
    if (!(mLayerProperties.mType == LayerType::None && fitsOnLayer())) return false;
    if (mComputedFields.mNeedLayerForFunctors) return true;
    if (mLayerProperties.mImageFilter != nullptr) {                       // ★ 命中这里
        if (kDynamicVKAllocateTrace) {
            ALOGE_AND_TRACE("promotedToLayer: mLayerProperties.mImageFilter");
        }
        return true;
    }
    if (mLayerProperties.getStretchEffect().requiresLayer()) return true;
    if (mLayerProperties.hasSelfBlurBlend()) return true;
    if (!MathUtils::isZero(mPrimitiveFields.mAlpha) && mPrimitiveFields.mAlpha < 1 &&
        mPrimitiveFields.mHasOverlappingRendering) return true;
    return false;
}
 
LayerType effectiveLayerType() const {
    return CC_UNLIKELY(promotedToLayer()) ? LayerType::RenderLayer : mLayerProperties.mType;
}
 

总结一句话setRenderEffect(非空)mImageFilter 不是 nullptr → promotedToLayer() 返回 true → 这个 view 必须开一块离屏画布画 → GPU 必须申请显存 → allocateImageMemory 被触发。这是一条必然命中的因果链。

4.3.2 drawLayer trace 的精确出处

代码佐证SkiaPipeline.cpp:128-136):

const RenderProperties& properties = layerNode->properties();
const SkRect bounds = SkRect::MakeWH(properties.getWidth(), properties.getHeight());
if (properties.getClipToBounds() && layerCanvas->quickReject(bounds)) {
    return false;
}
ATRACE_FORMAT("drawLayer [%s] %.1f x %.1f", layerNode->getName(), bounds.width(),
              bounds.height());
 

4.3.3 为什么 VulkanAMDMemoryAllocator::allocateImageMemory 只在首帧爆发?

代码佐证RenderNode.cpp:705-783):

std::optional<RenderNode::SnapshotResult> RenderNode::updateSnapshotIfRequired(
    GrRecordingContext* context, const SkImageFilter* imageFilter, const SkIRect& clipBounds) {
    auto* layerSurface = getLayerSurface();
    if (layerSurface == nullptr) return std::nullopt;
 
    sk_sp<SkImage> snapshot = layerSurface->makeImageSnapshot();
    ...
    bool update = mImageFilterClipBounds != clipBounds;
    bool selfBlurChanged = MiBlurBlendUtils::instance().selfBlurBlend(this, snapshot, update);
 
    if (imageFilter == nullptr) {
        ...
    } else if (mSnapshotResult.snapshot == nullptr ||
               imageFilter != mTargetImageFilter.get() ||      // ★ 指针不同 → 必走重做
               mImageFilterClipBounds != clipBounds ||
               selfBlurChanged ||
               mTargetImageFilterLayerSurfaceGenerationId != layerSurfaceGenerationId) {
        if (context) {
            mSnapshotResult.snapshot = SkImages::MakeWithFilter(
                    context, snapshot, imageFilter, subset, clipBounds, &mSnapshotResult.outSubset,
                    &mSnapshotResult.outOffset, getCustomVertexShader());        // ★ 在 GPU 上分配新 image
        }
        mTargetImageFilter = sk_ref_sp(imageFilter);
        ...
    }
    return mSnapshotResult;
}
 

附加放大效应:MIUI 启用了 enableRenderEffectCache=trueProperties.cpp:68),但 cache 命中条件是”imageFilter 指针 + clipBounds + generationID 都不变”,业务侧的”每帧 new effect”恰好把这个 cache 完全击穿

4.3.4 ★ 一帧内每次 allocateImageMemory 精确对应到代码哪一行?(实测对账)

用户用 7.D 中的 persist.kg.mask.skip_* 三档开关实测得到

业务侧动作实测 allocateImageMemory 次数含义
三个 setRenderEffect 全部 skip3与 MaskFade 无关的”基线分配”
启用 1 个 view 的 setRenderEffect3 + 2 = 5每个 View 触发 2 次大块 alloc
启用 2 个 view3 + 2×2 = 7
启用 3 个 view(线上现状)3 + 2×3 = 9与 trace 现场一致

实测结论

  • 6 次”大块”alloc = 3 全屏 View × 2 次/View,单块 ≈ 27.4 MB,合计 ≈ 164 MB,全部由 applyEffect 那 3 行 setRenderEffect 直接引发;
  • 3 次”小块”基线 alloc:与 MaskFade 无关,对应 NumStateView 348×51TextView 157×51 的 saveLayer 与 MiuiHollowBatteryMeterIconView 70×50 的硬件层;体量 < 0.1 MB,对绘帧时间贡献可忽略。

hwui 流水线层面,单个全屏 RenderNode 首次 promote 后的 GPU 分配链

RenderNodeDrawable::drawContent()                              [pipeline/skia/RenderNodeDrawable.cpp:486]
  └─ effectiveLayerType()==RenderLayer
       ├─ ① RenderNode::getLayerSurface()                      ← alloc #1: layerSurface 后端 image
       │     └─ SkSurfaces::RenderTarget(grContext, ...)
       │         └─ GrVkGpu::createTexture → VulkanAMDMemoryAllocator::allocateImageMemory
       │     [color attachment, 27.4 MB]

       ├─ layerSurface->makeImageSnapshot()
       │     └─ 共享上面 layerSurface 的后端 GrTexture,并作为滤镜可采样输入
       │       (这就是为什么实测没有"滤镜输入"独立分配)

       └─ RenderNode::updateSnapshotIfRequired(ctx, imageFilter, clipBounds)
             └─ SkImages::MakeWithFilter(ctx, snapshot, imageFilter, ...)
                   └─ ② SkRuntimeImageFilter::onFilterImage 输出侧  ← alloc #2: filter output image
                         ctx.makeSurface(outputInfo)
                         [27.4 MB]
 

实测精确映射表(按 RenderNodeDrawable 调用栈顺序,3 view 同帧依次进入):

序号来源 view对应代码单块体量
#1主时钟容器 — layerSurfaceRenderNode::getLayerSurface() → SkSurfaces::RenderTarget(...)27.4 MB
#2主时钟容器 — MakeWithFilter 输出RenderNode.cpp:723 SkImages::MakeWithFilter(...)27.4 MB
#3次时钟容器 — layerSurface同 #127.4 MB
#4次时钟容器 — MakeWithFilter 输出同 #227.4 MB
#5keyguard_translation_info — layerSurface同 #127.4 MB
#6keyguard_translation_info — MakeWithFilter 输出同 #227.4 MB
#7NumStateView alpha caused saveLayer 348×51BaseRecordingCanvas::saveLayerAlpha(NumStateView 0.99 alpha workaround)< 0.1 MB
#8子 TextView alpha caused saveLayer 157×51同 #7< 0.1 MB
#9MiuiHollowBatteryMeterIconView 70×50 硬件层MiuiHollowBatteryMeterIconView.kt:105 setLayerType(LAYER_TYPE_HARDWARE,...)< 0.1 MB

首帧 GPU 显存压力 ≈ 6 × 27.4 MB + 3 × 极小 ≈ 164 MB,其中 164 MB 全部来自 KeyguardMaskFadeEffectController.applyEffect() 那 3 行 setRenderEffect——这是优化的唯一目标。

为什么后续帧不再触发? 三层缓存逐级兜底:

  1. 节点级缓存 mLayerSurfaceRenderNode::getLayerSurface() 返回的是缓存的 SkSurface 对象,只在尺寸变化时才重建。
  2. Skia 资源池 GrResourceCache scrap pool:上一帧释放的 SkImage 进入”可清理但暂留”队列,下一帧申请相同尺寸/格式时直接从池里取。
  3. Vulkan VMA 内存池:即使 Skia 决定要新 image,VMA 已经从首帧从操作系统拿到了大块 device memory,后续 vkAllocateMemory 走”在已有大块里切一小块”(sub-allocate),不发起新的 OS 系统调用

首帧痛 = 三层缓存全冷;从第 2 帧起,业务侧每帧 new RenderEffect 只产生 CPU 侧 MakeWithFilter 重录开销和 Skia 绘制 op 重建,没有 OS-level Vulkan 分配

4.4 触发条件:为什么”点击数字态展开”会触发?

KeyguardMaskFadeEffectController.start() 监听的是 IFaceMaskInteractor.maskState 这个 Flow。trace 中实测到了一对完整的 visible=true / visible=false 切换:

KeyguardMaskFade  maskState: visible=true,  fadeEndY=127
KeyguardMaskFade  maskState: visible=false, fadeEndY=127
 

点击 NotificationNumStateView 走完 goToList → onClickToList → CLICK_SHOW_LIST → updateStackNotifTopWithAnimation 后,通知列表会向上挤压锁屏画报/时钟,IFaceMaskInteractor 据此把 maskState 切到 visible=true 让画报/时钟做 fade 渐隐;动画结束再 visible=false 收尾。真正”贵”的只是 ratio 由 0 跳到非 0 的那一帧——3 个 RenderNode 在那一帧首次 promote 到 RenderLayer,触发实测 6 次大块(≈164 MB)+ 3 次小块基线 GPU image 冷分配。


5. 嫌疑代码排名(按”嫌疑度 × 影响面”打分)

排名嫌疑代码离屏 layer 触发机制像素体量嫌疑度修复建议
🥇 1KeyguardMaskFadeEffectController.applyEffect() KeyguardMaskFadeEffectController.kt:108-125首帧 RenderEffect.createRuntimeShaderEffect()view.setRenderEffect(effect) ×3 → LayerProperties::setImageFilter(newPtr)promotedToLayer() 命中 mImageFilter!=nullptr 分支 → 三个全屏容器同帧首次 promote 成 RenderLayer3200×2136 × 3 view × 2 次/view = 6 次大块 alloc ≈ 164 MB,集中在首帧✅ 100%见下文修复方案
🥈 2KeyguardTranslationInfoView(match_parent 容器)keyguard_panel_view.xml:51-64自身没有 setLayerType,但 layout 是 match_parent,被排名 1 触发后承担最大像素量3200×2136✅ 100%把 RenderEffect 缩小到画报实际可见区域
🥉 3NotificationNumStateView 子 view 的 SHOW_ALPHA = 0.99f NumStateViewAnimateExt.kt:372 + NotificationNumStateView.kt:369-372forceHasOverlappingRendering(true) + alpha=0.99 命中 promotedToLayer() 的最后一个分支348×51 + 157×51(小)✅ 100%改为只在动画进行中临时 0.99
4MiuiHollowBatteryMeterIconView.onAttachedToWindow MiuiHollowBatteryMeterIconView.kt:105显式 setLayerType(LAYER_TYPE_HARDWARE, null)70×50(极小)✅ 100%必要 layer,可忽略
5ViewState.startAnimationToState 动画期间 setLayerType ViewState.java:584动画启动时 child.setLayerType(LAYER_TYPE_HARDWARE)通知 row 大小⚠️ 可能AOSP 既有优化模式
6MiBlurBlendUtils::instance().selfBlurBlend(...) RenderNode.cpp:734MIUI 自定义 blur,让 promotedToLayer() 返回 true视具体 view❓ 怀疑需进一步确认

结论:第 1 名的嫌疑度与影响面远超其它候选——实测 6 大块 ≈164 MB 全部由它产生


6. 阻塞链

[业务] 用户点击 NotificationNumStateView
    ↓ NotificationNumStateAreaTouchHandler.onClicked → ViewModel.goToList()
[业务] NotificationContainerViewModel.onClickToList()
    ↓ CLICK_SHOW_LIST → 通知列表上推、锁屏画报/时钟需要 fade
[业务] IFaceMaskInteractor 发 maskState(visible=true, fadeEndY=127)
    ↓ KeyguardMaskFadeEffectController.start() 中 collect 命中
[业务] folme.to(RATIO_PROPERTY, 1f, ...) 启动 spring 动画
    ↓ ratio 由 0 → 非 0 触发首次 applyEffect
[业务] ★ 首帧 applyEffect(fadeEndY, ratio>0)
    ↓ RenderEffect.createRuntimeShaderEffect(shader,"content") 返回新 SkImageFilter*
[业务] view.setRenderEffect(newEffect) ×3
    ↓ JNI nSetRenderEffect → LayerProperties::setImageFilter(newPtr)
[hwui] RenderProperties::promotedToLayer() 三个 view 命中 mImageFilter!=nullptr 分支
    ↓ effectiveLayerType()==RenderLayer,三节点同帧入 LayerUpdateQueue
[hwui] SkiaPipeline::renderLayerImpl 同帧 3 次 ATRACE drawLayer [<view>] WxH
    ↓ 每个 view 首次 getLayerSurface() → SkSurfaces::RenderTarget(...)
[GPU] ★ alloc #1/#3/#5:3 × layerSurface(27.4 MB × 3 = 82 MB)

[hwui] 每个 view 进入 updateSnapshotIfRequired(ctx, newPtr, clipBounds)
    ↓ mSnapshotResult.snapshot==nullptr → 走 SkImages::MakeWithFilter
[GPU] ★ alloc #2/#4/#6:3 × MakeWithFilter output(27.4 MB × 3 = 82 MB)

[GPU] 同帧另外触发 3 次小块基线分配
    #7 NumStateView 348×51 saveLayerAlpha (NumStateView 0.99 alpha)
    #8 子 TextView 157×51 saveLayerAlpha
    #9 MiuiHollowBatteryMeterIconView 70×50 setLayerType(HARDWARE)

[GPU] skgpu::VulkanAMDMemoryAllocator::allocateImageMemory 首帧实测共 9 次
    ↓ 6 次大块向 Vulkan device 申请 ≈164 MB
[结果] ★ 首帧 RenderThread 严重超时 → 用户感知"点击瞬间卡一下"

[后续帧] 业务每帧仍 new effect ptr,但 mLayerSurface/Skia scrap/Vulkan VMA pool
        三层池逐级吸收 → allocateImageMemory 不再被命中
 

7. 修复建议

⚠️ 核心认知前置

7.0 方案总览(按推荐度排序)

#方案思路首帧大块 alloc单块体量视觉等价性实施代价推荐度
B复用 RenderEffect 实例applyEffect 不再每帧 new,shader uniform 原地更新6 → 627.4 MB✅ 完全等价极小★★★★★
A缩小 effect 作用域到画报子 view从 match_parent 容器下沉到真正可见的画报 view6 → 627.4 MB → ~3 MB✅ 完全等价★★★★★
C静态预热 layer(onAttach 即 setLayerType)三个容器 onAttach 时就 LAYER_TYPE_HARDWARE6 → 327.4 MB✅ 完全等价★★★★
D错峰预创建 effect锁屏激活/亮屏时就挂 ratio=0 effect6 → 0(移到亮屏帧)27.4 MB✅ 完全等价★★★★
ELinearGradient + DST_IN 替代 RuntimeShader用 Canvas 标准渐变 API 实现同样的 alpha 渐变6 → 0~3< 1 MB⚠️ 需逐像素验证★★★
F减少被 promote 的 view 数评估时钟/画报是否互斥可见6 → 227.4 MB⚠️ 取决于业务★★★
Ghwui 侧 VMA 池预热系统层启动时跑 dummy 全屏 surface 让 VMA 暖起6 仍 627.4 MB★★
H节流 collect (.conflate())减少 ratio collector 调用频次首帧 6 不变27.4 MB极小★(首帧无效)

🎯 最佳组合 = B + A + C:三者改动都不大、视觉完全等价、互不冲突,叠加后效果是把首帧 6 次 27.4 MB 大块降到 3 次 3 MB 小块 —— 总显存压力从 ≈164 MB 降到 < 10 MB。

7.1 方案 B — 复用 RenderEffect 实例(必做基础项)

原理

当前 applyEffect 每帧 RenderEffect.createRuntimeShaderEffect(shader, "content"),每次返回新的 native SkImageFilter*。这导致:

  1. LayerProperties::setImageFilter 永远把”指针不一样”判定为脏,每帧设脏一次 RenderNode;
  2. updateSnapshotIfRequired 永远走 imageFilter != mTargetImageFilter.get() 分支,每帧重算 MakeWithFilter
  3. enableRenderEffectCache 设计了”上一帧滤镜结果可复用”的 cache,但 cache 命中条件含 imageFilter 指针,每帧新指针把 cache 完全击穿。

完整代码

@SysUISingleton
class KeyguardMaskFadeEffectController @Inject constructor(...) : CoreStartable {
 
    private var fadeShader: RuntimeShader? = null
    private var fadeEffect: RenderEffect? = null   // ★ 新增:缓存的 effect 实例
 
    private fun ensureShader() {
        if (fadeShader == null) {
            fadeShader = RuntimeShader(AGSL_FADE_SHADER)
        }
        if (fadeEffect == null) {
            // ★ 一次创建,永久持有;只要 shader 实例不变,effect 即可复用
            fadeEffect = RenderEffect.createRuntimeShaderEffect(fadeShader!!, "content")
        }
    }
 
    private fun applyEffect(fadeEndY: Float, ratio: Float) {
        val shader = fadeShader ?: return
        val effect = fadeEffect ?: return                       // ★ 取缓存的 effect
        val fadeStartY = fadeEndY + fadeGradientHeightPx.value
 
        shader.setFloatUniform("fadeStartY", fadeStartY)
        shader.setFloatUniform("fadeEndY",   fadeEndY)
        shader.setFloatUniform("ratio",      ratio)
 
        findPrimaryClock()?.setRenderEffect(effect)
        findSecondaryClock()?.setRenderEffect(effect)
        val translationInfo = findTranslationInfo()
        if (findMagazineView()?.parent === translationInfo) {
            translationInfo?.setRenderEffect(effect)
        }
        maskFadeEffectProvider.setFadeEffect(effect)
    }
 
    private fun clearEffect() {
        findPrimaryClock()?.setRenderEffect(null)
        findSecondaryClock()?.setRenderEffect(null)
        findTranslationInfo()?.setRenderEffect(null)
        maskFadeEffectProvider.setFadeEffect(null)
    }
}
 

视觉等价性论证

  • AGSL shader 源码字符串没变 → GPU 上跑的像素着色逻辑没变;
  • uniform 通过 setFloatUniform 原地更新,每帧的数值与原版完全一致;
  • 唯一差异是 native 侧 SkImageFilter* 不再每帧 new。该指针是 hwui 内部判等用,不参与像素计算
  • 结论:逐像素一致

7.2 方案 A — 缩小 effect 作用域:动态最优挂载点

三个挂载点的可下沉性(基于源码确认)

挂载点当前尺寸可缩小?替代目标
translationInfo(magazine)3200×2136✅ 100% 可缩R.id.unlock_screen_lock_screen_magazine_infoLockScreenMagazineClockView,wrap_content × wrap_content)
primaryClock(主时钟)3200×2136⚠️ 依赖时钟模板classic / classic_plus / smart_frame / rhombus_notification 这几款根 view 是 match_parent × wrap_content,可下沉;magazine_a/b、rhombus、aesthetics_a、doodle、pad_exclusive_a、classic_signature 根 view 本身就是 match_parent × match_parent,下沉无效
secondaryClock(副时钟)3200×2136✅ 多数情况直接 skipKeyguardClockContainer.kt:561-593 仅 split-clock / new-layer 模板才填充,否则容器为空

证据来源:miuiclock.aar 解出的 out/soong/.../miuiclock/.../layout/miui_clock_layout_*.xml;KeyguardClockContainer.kt:508-511 / 561-593。

实现:运行时根据 layoutParams 自适应

private fun bestPrimaryTarget(): View? {
    val pri = findPrimaryClock() ?: return null
    val inner = pri.getChildAt(0) ?: return pri
    val lp = inner.layoutParams ?: return pri
    return if (lp.height == ViewGroup.LayoutParams.MATCH_PARENT) pri else inner
}
 
private fun bestSecondaryTarget(): View? {
    val sec = findSecondaryClock() ?: return null
    if (sec.childCount == 0) return null  // 空容器,直接 skip
    val inner = sec.getChildAt(0)
    val lp = inner.layoutParams ?: return sec
    return if (lp.height == ViewGroup.LayoutParams.MATCH_PARENT) sec else inner
}
 
private fun bestMagazineTarget(): View? =
    windowView.findViewById(R.id.unlock_screen_lock_screen_magazine_info)
        ?.takeIf { it.visibility == View.VISIBLE }
        ?: findTranslationInfo()
 

收益分档

场景优化前优化后缩减
classic 时钟 + 非 split-clock(最常见)6 块 ≈ 164 MB~25 MB~85%
magazine 时钟 + 非 split-clock6 块 ≈ 164 MB~55 MB~66%
任意时钟 + split-clock6 块 ≈ 164 MB~80–110 MB~33–50%

AGSL shader 的 fadeEndY 坐标系适配

private fun applyEffect(fadeEndYInWindow: Float, ratio: Float) {
    val shader = fadeShader ?: return
    val effect = fadeEffect ?: return
    fun applyTo(view: View?) {
        view ?: return
        val loc = IntArray(2); view.getLocationInWindow(loc)
        val localEnd = fadeEndYInWindow - loc[1]
        val localStart = localEnd + fadeGradientHeightPx.value
        shader.setFloatUniform("fadeStartY", localStart)
        shader.setFloatUniform("fadeEndY",   localEnd)
        shader.setFloatUniform("ratio",      ratio)
        view.setRenderEffect(effect)
    }
    applyTo(bestPrimaryTarget())
    applyTo(bestSecondaryTarget())
    applyTo(bestMagazineTarget())
    maskFadeEffectProvider.setFadeEffect(effect)
}
 

7.3 方案 C — 静态预热 layer(onAttach 即 setLayerType)

原理

实测 6 次大块 = 3 view × (1 layerSurface + 1 MakeWithFilter 输出)。其中 layerSurface 这 3 块完全不依赖 effect,只取决于”这个 view 是否走离屏路径”。如果在 view onAttachedToWindow 时就显式 setLayerType(LAYER_TYPE_HARDWARE, null)getLayerSurface() 会在那一帧就把 SkSurface 创建好——等到 maskState=true 那一关键帧时,layerSurface 不再冷分配,只剩下 3 次 MakeWithFilter 输出的分配。

完整代码

override fun start() {
    val animationRatio = MutableStateFlow(0f)
    val folme = Folme.useValue(animationRatio)
 
    scope.launch {
        faceMaskInteractor.maskState.collect { (visible, fadeEndY) ->
            currentFadeEndY = fadeEndY.toFloat()
            if (visible) {
                ensureShader()
                folme.to(RATIO_PROPERTY, 1f, AnimConfig().setEase(showEase))
            } else {
                folme.to(RATIO_PROPERTY, 0f, AnimConfig().setEase(hideEase))
            }
        }
    }
 
    scope.launch {
        animationRatio.collect { ratio ->
            if (ratio <= 0f) clearEffect() else applyEffect(currentFadeEndY, ratio)
        }
    }
 
    // ★ 新增:等 windowView attach 后 prewarm 三个容器的 layerSurface
    windowView.post {
        prewarmLayer(findPrimaryClock())
        prewarmLayer(findSecondaryClock())
        prewarmLayer(findTranslationInfo())
    }
}
 
private fun prewarmLayer(view: View?) {
    view ?: return
    if (view.layerType == View.LAYER_TYPE_HARDWARE) return
    view.setLayerType(View.LAYER_TYPE_HARDWARE, null)
    view.invalidate()
}
 

7.4 方案 D — 错峰预创建 effect(治本,把首帧分配挪到无感知时间点)

原理

把 maskFade 的首帧分配,挪到用户感知不到的”亮屏帧”或”锁屏激活帧”。具体做法:在锁屏 attach 完成、用户未交互的空闲期,主动调一次 applyEffect(任何 fadeEndY, ratio = 0)

  • ratio=0 时 AGSL shader 输出 mix(1, t, 0) = 1 → 像素 alpha=1 → 视觉上等于”什么都没渐隐”,用户看不见任何变化
  • 但 hwui 视角:mImageFilter != nullptr,三个 view 已经走 RenderLayer 路径,layerSurface + MakeWithFilter 输出都已分配,VMA 池暖了;
  • 真正点击数字态展开时,applyEffect 改 uniform、ratio 渐变到 1——所有 GPU 大块分配命中池,零 OS 分配

完整代码

override fun start() {
    val animationRatio = MutableStateFlow(0f)
    val folme = Folme.useValue(animationRatio)
 
    scope.launch {
        faceMaskInteractor.maskState.collect { (visible, fadeEndY) ->
            currentFadeEndY = fadeEndY.toFloat()
            if (visible) {
                ensureShader()
                folme.to(RATIO_PROPERTY, 1f, AnimConfig().setEase(showEase))
            } else {
                folme.to(RATIO_PROPERTY, 0f, AnimConfig().setEase(hideEase))
            }
        }
    }
 
    scope.launch {
        animationRatio.collect { ratio ->
            applyEffect(currentFadeEndY, ratio)
        }
    }
 
    windowView.post {
        ensureShader()
        applyEffect(windowView.height * 0.05f, ratio = 0f)
    }
}
 
override fun onDestroy() {
    clearEffect()
}
 

7.5 方案 E — LinearGradient + DST_IN 蒙版替代 RuntimeShader(最彻底,但工程量大)

原理

AGSL_FADE_SHADER 数学等价:

alpha(y) = mix(1, saturate((y - fadeEndY) / (fadeStartY - fadeEndY)), ratio)
 

ratio = 1alpha = saturate((y - fadeEndY) / range),是一个标准的 1D 线性渐变。Canvas 标准 API 用 LinearGradient 可以直接表达。

实施思路(伪代码)

class FadeOverlayContainer(...) : FrameLayout(...) {
    private val fadePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        blendMode = BlendMode.DST_IN
    }
    var fadeEndY = 0f
    var fadeStartY = 0f
    var ratio = 0f
 
    override fun dispatchDraw(canvas: Canvas) {
        if (ratio <= 0f) { super.dispatchDraw(canvas); return }
        val sc = canvas.saveLayer(null, null)
        super.dispatchDraw(canvas)
        fadePaint.shader = LinearGradient(
            0f, fadeEndY, 0f, fadeStartY,
            Color.argb((255 * (1 - ratio)).toInt(), 255, 255, 255),
            Color.WHITE,
            Shader.TileMode.CLAMP
        )
        canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), fadePaint)
        canvas.restoreToCount(sc)
    }
}
 

与原版的等价性

  • 原 AGSL:out.a = src.a * mix(1, t, ratio) = src.a * (1 - ratio + ratio*t)
  • DST_IN + 上述 LinearGradient:out.a = src.a * lerp(1-ratio, 1, t) = src.a * (1 - ratio + ratio*t)
  • 数学完全等价

7.6 方案 F — 减少被 promote 的 view 数

业务排查项:时钟(primary/secondary)和画报(translationInfo)在 maskFade 触发时是否互斥可见

private fun applyEffect(fadeEndY: Float, ratio: Float) {
    val effect = fadeEffect ?: return
    listOfNotNull(findPrimaryClock(), findSecondaryClock(), findTranslationInfo())
        .filter { it.visibility == View.VISIBLE && it.alpha > 0f }
        .forEach { it.setRenderEffect(effect) }
}
 

收益:6 大块 → 2 大块(仅当前可见 view),≈164 MB → ≈55 MB。视觉等价。

7.9 推荐落地顺序与验证矩阵

┌─────────────────────────────────────────────────────────────┐
│                    建议落地阶梯                              │
├─────────────────────────────────────────────────────────────┤
│ P0 (必做, 0.5 天):  方案 B  →  effect 实例复用              │
│ P0 (必做, 1 天):    方案 A  →  下沉到画报子 view            │
├─────────────────────────────────────────────────────────────┤
│ P1 (1 天):          方案 C  →  onAttach 静态预热            │
│ P1 (2 天):          方案 D  →  ratio=0 错峰预创建           │
├─────────────────────────────────────────────────────────────┤
│ P2 (业务确认):      方案 F  →  互斥可见性裁剪               │
│ P3 (备选):          方案 E  →  LinearGradient 替代          │
└─────────────────────────────────────────────────────────────┘
 
阶段实测验证方法通过标准
B 落地后persist.kg.mask.skip_* 开关全开,跑动画,看 trace 中 auto skgpu::VulkanAMDMemoryAllocator::allocateImageMemory 字符串数量与 baseline 一致;MakeWithFilter trace 数显著减少
B + A 落地后加 log ALOG("allocImg %dx%d", w, h),看大块 image 尺寸大块尺寸从 3200×2136 降到画报子 view 量级
B + A + C 落地后trace 中 drawLayer [<view>] 在 maskState=true 之前的某帧(亮屏帧)就出现首帧 alloc 减少 3 次
B + A + C + D 落地后trace 中关键帧 allocateImageMemory 计数关键帧 ≤ 3 次
截图对比(必做)启用方案前后录屏,逐帧抽样对比渐变区域、边界、动画曲线像素差 ≤ 1/255

7.10 顺手项

NumStateViewAnimateExt.kt:372SHOW_ALPHA = 0.99f 是为规避原生阴影 bug 的 workaround,但代价是每帧 saveLayerAlpha。建议动画进行中保持 0.99,动画结束(onAnimationEnd)恢复 1.0。


8. 关键证据清单

#类型来源关键内容证明了什么
1trace 字符串test_20260526-145116.pftracedrawLayer [KeyguardTranslationInfoView] 3200.0 x 2136.0全屏离屏 layer 真实存在
2trace 字符串test_20260526-145116.pftraceauto skgpu::VulkanAMDMemoryAllocator::allocateImageMemory(...)RenderThread 上确有 GPU image 大块分配
3trace 字符串test_20260526-145116.pftraceKeyguardMaskFade maskState: visible=true/false, fadeEndY=127MaskFade 动画完整跑过一次
4trace 字符串test_20260526-145116.pftracealpha caused saveLayer 348x51 / 157x51NumStateView 因 0.99 alpha 触发 saveLayer
5用户实测persist.kg.mask.skip_* 三档开关验证全 skip = 3;每启用一个 view +2;全启用 = 9实证每个 view 触发 2 次大块 alloc
6trace 实测traceallocateImageMemory 仅在 maskState 切换后首帧密集出现排除”每帧分配风暴”假设
7业务源码KeyguardMaskFadeEffectController.kt:108-125applyEffect 三个 view 同帧 setRenderEffect 且每帧 new effect首帧 6 大块 alloc 的代码出处
8布局 xmlkeyguard_panel_view.xml:51-64KeyguardTranslationInfoView 是 match_parent解释单块 layer 为何到 3200×2136
9hwui 源码android_graphics_RenderNode.cpp:285-289setRenderEffect JNI → setImageFilter + SET_AND_DIRTY(GENERIC)业务调用直接对 RenderNode 设脏
10hwui 源码RenderProperties.cpp:62-65LayerProperties::setImageFilter 仅当指针相同才跳过每帧 new effect 必改 mImageFilter
11hwui 源码RenderProperties.h:609-663promotedToLayer() 命中 mImageFilter != nullptr → RenderLayersetRenderEffect 强制升级离屏 layer
12hwui 源码SkiaPipeline.cpp:135ATRACE_FORMAT("drawLayer [%s] %.1f x %.1f", ...)与 trace 字符串完全对应
13hwui 源码RenderNode.cpp:705-783updateSnapshotIfRequired:imageFilter 指针变 → SkImages::MakeWithFilter(...)filter output GPU image 的分配点
14hwui 源码RenderNodeDrawable.cpp:486-492调用 updateSnapshotIfRequired(...)每帧 onDraw 进入此路径
15hwui 源码Properties.cpp:68 + RenderProperties.h:563-564enableRenderEffectCache=true,命中条件含 imageFilter 指针cache 在首帧无救
16业务源码NumStateViewAnimateExt.kt:372SHOW_ALPHA = 0.99f 注释”原生阴影在 alpha=1 时会扣除控件下方的阴影”#4 saveLayer 的来源
17业务源码NotificationNumStateView.kt:369-372forceHasOverlappingRendering(true) + alpha 0.99完整闭环 NumStateView saveLayer

9. 不确定项 & 后续验证

  1. kDynamicVKAllocateTrace 默认关闭:trace 没有抓到 promotedToLayer: mLayerProperties.mImageFilter 的 ALOGE_AND_TRACE。调试构建中开启该 flag 可把”哪条分支让 view 升级 layer”打到 trace。
  2. IFaceMaskInteractor.maskState 的发射条件未读:建议补充确认是否在”点击展开数字态”以外的路径(例如下拉通知阴影、AOD 切换)也会触发同一动画。
  3. setFloatUniform 对 SkImageFilter.uniqueID / generationID 的影响:方案 B(effect 复用)的 CPU 路径收益取决于此。最坏情况退化到当前线上行为,无视觉风险。
  4. 方案 E(LinearGradient 替代)的浮点精度:数学上完全等价,但 Skia 在不同 BlendMode 下的着色精度可能有 < 1/255 差异,需 QA 截图逐像素对比。
  5. Vulkan VMA sub-allocate 阈值与 Skia scrap 池 budgetGrResourceCache::setLimit 当前配置未确证。如设备厂商把 budget 调得很低,“后续帧不再分配”的假设可能被打破。
  6. 方案 C/D 的常驻显存代价:必须搭配方案 A(缩到画报子 view)才能把常驻显存从 ≈164 MB 降到 ≈11 MB。

本次分析使用了的知识切片

  • 无(本场景未匹配现有切片,已规划新切片)

本次分析问题所总结的方法论

  • 新增切片stability/render/setrendereffect_layer_storm_alloc_image.md排查 RenderThread 频繁 VulkanAMDMemoryAllocator::allocateImageMemory 的方法论:从 perfetto strings 抓 drawLayer [%s] %fx%f 找到被升级到 layer 的 view → 反查代码中谁调用了 setRenderEffect / setHasOverlappingRendering+alpha<1 / setLayerType / Stretch / hasSelfBlurBlend → 用 RenderProperties::promotedToLayer() 五大分支对号入座 → 看 effect 是否每帧 new 击穿 enableRenderEffectCache。