锁屏点击数字态展开 — 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=true(Properties.cpp:68),但 cache 命中条件是”imageFilter 指针 + clipBounds + generationID 都不变”,业务侧的”每帧 new effect”恰好把这个 cache 完全击穿。
4.3.4 ★ 一帧内每次 allocateImageMemory 精确对应到代码哪一行?(实测对账)
用户用 7.D 中的 persist.kg.mask.skip_* 三档开关实测得到:
| 业务侧动作 | 实测 allocateImageMemory 次数 | 含义 |
|---|---|---|
三个 setRenderEffect 全部 skip | 3 | 与 MaskFade 无关的”基线分配” |
启用 1 个 view 的 setRenderEffect | 3 + 2 = 5 | 每个 View 触发 2 次大块 alloc |
| 启用 2 个 view | 3 + 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×51、TextView 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 | 主时钟容器 — layerSurface | RenderNode::getLayerSurface() → SkSurfaces::RenderTarget(...) | 27.4 MB |
| #2 | 主时钟容器 — MakeWithFilter 输出 | RenderNode.cpp:723 SkImages::MakeWithFilter(...) | 27.4 MB |
| #3 | 次时钟容器 — layerSurface | 同 #1 | 27.4 MB |
| #4 | 次时钟容器 — MakeWithFilter 输出 | 同 #2 | 27.4 MB |
| #5 | keyguard_translation_info — layerSurface | 同 #1 | 27.4 MB |
| #6 | keyguard_translation_info — MakeWithFilter 输出 | 同 #2 | 27.4 MB |
| #7 | NumStateView alpha caused saveLayer 348×51 | BaseRecordingCanvas::saveLayerAlpha(NumStateView 0.99 alpha workaround) | < 0.1 MB |
| #8 | 子 TextView alpha caused saveLayer 157×51 | 同 #7 | < 0.1 MB |
| #9 | MiuiHollowBatteryMeterIconView 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——这是优化的唯一目标。
为什么后续帧不再触发? 三层缓存逐级兜底:
- 节点级缓存 mLayerSurface:
RenderNode::getLayerSurface()返回的是缓存的 SkSurface 对象,只在尺寸变化时才重建。 - Skia 资源池 GrResourceCache scrap pool:上一帧释放的 SkImage 进入”可清理但暂留”队列,下一帧申请相同尺寸/格式时直接从池里取。
- 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 触发机制 | 像素体量 | 嫌疑度 | 修复建议 |
|---|---|---|---|---|---|
| 🥇 1 | KeyguardMaskFadeEffectController.applyEffect() KeyguardMaskFadeEffectController.kt:108-125 | 首帧 RenderEffect.createRuntimeShaderEffect() → view.setRenderEffect(effect) ×3 → LayerProperties::setImageFilter(newPtr) → promotedToLayer() 命中 mImageFilter!=nullptr 分支 → 三个全屏容器同帧首次 promote 成 RenderLayer | 3200×2136 × 3 view × 2 次/view = 6 次大块 alloc ≈ 164 MB,集中在首帧 | ✅ 100% | 见下文修复方案 |
| 🥈 2 | KeyguardTranslationInfoView(match_parent 容器)keyguard_panel_view.xml:51-64 | 自身没有 setLayerType,但 layout 是 match_parent,被排名 1 触发后承担最大像素量 | 3200×2136 | ✅ 100% | 把 RenderEffect 缩小到画报实际可见区域 |
| 🥉 3 | NotificationNumStateView 子 view 的 SHOW_ALPHA = 0.99f NumStateViewAnimateExt.kt:372 + NotificationNumStateView.kt:369-372 | forceHasOverlappingRendering(true) + alpha=0.99 命中 promotedToLayer() 的最后一个分支 | 348×51 + 157×51(小) | ✅ 100% | 改为只在动画进行中临时 0.99 |
| 4 | MiuiHollowBatteryMeterIconView.onAttachedToWindow MiuiHollowBatteryMeterIconView.kt:105 | 显式 setLayerType(LAYER_TYPE_HARDWARE, null) | 70×50(极小) | ✅ 100% | 必要 layer,可忽略 |
| 5 | ViewState.startAnimationToState 动画期间 setLayerType ViewState.java:584 | 动画启动时 child.setLayerType(LAYER_TYPE_HARDWARE) | 通知 row 大小 | ⚠️ 可能 | AOSP 既有优化模式 |
| 6 | MiBlurBlendUtils::instance().selfBlurBlend(...) RenderNode.cpp:734 | MIUI 自定义 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 → 6 | 27.4 MB | ✅ 完全等价 | 极小 | ★★★★★ |
| A | 缩小 effect 作用域到画报子 view | 从 match_parent 容器下沉到真正可见的画报 view | 6 → 6 | 27.4 MB → ~3 MB | ✅ 完全等价 | 小 | ★★★★★ |
| C | 静态预热 layer(onAttach 即 setLayerType) | 三个容器 onAttach 时就 LAYER_TYPE_HARDWARE | 6 → 3 | 27.4 MB | ✅ 完全等价 | 中 | ★★★★ |
| D | 错峰预创建 effect | 锁屏激活/亮屏时就挂 ratio=0 effect | 6 → 0(移到亮屏帧) | 27.4 MB | ✅ 完全等价 | 中 | ★★★★ |
| E | LinearGradient + DST_IN 替代 RuntimeShader | 用 Canvas 标准渐变 API 实现同样的 alpha 渐变 | 6 → 0~3 | < 1 MB | ⚠️ 需逐像素验证 | 大 | ★★★ |
| F | 减少被 promote 的 view 数 | 评估时钟/画报是否互斥可见 | 6 → 2 | 27.4 MB | ⚠️ 取决于业务 | 中 | ★★★ |
| G | hwui 侧 VMA 池预热 | 系统层启动时跑 dummy 全屏 surface 让 VMA 暖起 | 6 仍 6 | 27.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*。这导致:
LayerProperties::setImageFilter永远把”指针不一样”判定为脏,每帧设脏一次 RenderNode;updateSnapshotIfRequired永远走imageFilter != mTargetImageFilter.get()分支,每帧重算MakeWithFilter;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_info(LockScreenMagazineClockView,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 | ✅ 多数情况直接 skip | KeyguardClockContainer.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-clock | 6 块 ≈ 164 MB | ~55 MB | ~66% |
| 任意时钟 + split-clock | 6 块 ≈ 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 = 1:alpha = 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:372 的 SHOW_ALPHA = 0.99f 是为规避原生阴影 bug 的 workaround,但代价是每帧 saveLayerAlpha。建议动画进行中保持 0.99,动画结束(onAnimationEnd)恢复 1.0。
8. 关键证据清单
| # | 类型 | 来源 | 关键内容 | 证明了什么 |
|---|---|---|---|---|
| 1 | trace 字符串 | test_20260526-145116.pftrace | drawLayer [KeyguardTranslationInfoView] 3200.0 x 2136.0 | 全屏离屏 layer 真实存在 |
| 2 | trace 字符串 | test_20260526-145116.pftrace | auto skgpu::VulkanAMDMemoryAllocator::allocateImageMemory(...) | RenderThread 上确有 GPU image 大块分配 |
| 3 | trace 字符串 | test_20260526-145116.pftrace | KeyguardMaskFade maskState: visible=true/false, fadeEndY=127 | MaskFade 动画完整跑过一次 |
| 4 | trace 字符串 | test_20260526-145116.pftrace | alpha caused saveLayer 348x51 / 157x51 | NumStateView 因 0.99 alpha 触发 saveLayer |
| 5 | 用户实测 | persist.kg.mask.skip_* 三档开关验证 | 全 skip = 3;每启用一个 view +2;全启用 = 9 | 实证每个 view 触发 2 次大块 alloc |
| 6 | trace 实测 | trace | allocateImageMemory 仅在 maskState 切换后首帧密集出现 | 排除”每帧分配风暴”假设 |
| 7 | 业务源码 | KeyguardMaskFadeEffectController.kt:108-125 | applyEffect 三个 view 同帧 setRenderEffect 且每帧 new effect | 首帧 6 大块 alloc 的代码出处 |
| 8 | 布局 xml | keyguard_panel_view.xml:51-64 | KeyguardTranslationInfoView 是 match_parent | 解释单块 layer 为何到 3200×2136 |
| 9 | hwui 源码 | android_graphics_RenderNode.cpp:285-289 | setRenderEffect JNI → setImageFilter + SET_AND_DIRTY(GENERIC) | 业务调用直接对 RenderNode 设脏 |
| 10 | hwui 源码 | RenderProperties.cpp:62-65 | LayerProperties::setImageFilter 仅当指针相同才跳过 | 每帧 new effect 必改 mImageFilter |
| 11 | hwui 源码 | RenderProperties.h:609-663 | promotedToLayer() 命中 mImageFilter != nullptr → RenderLayer | setRenderEffect 强制升级离屏 layer |
| 12 | hwui 源码 | SkiaPipeline.cpp:135 | ATRACE_FORMAT("drawLayer [%s] %.1f x %.1f", ...) | 与 trace 字符串完全对应 |
| 13 | hwui 源码 | RenderNode.cpp:705-783 | updateSnapshotIfRequired:imageFilter 指针变 → SkImages::MakeWithFilter(...) | filter output GPU image 的分配点 |
| 14 | hwui 源码 | RenderNodeDrawable.cpp:486-492 | 调用 updateSnapshotIfRequired(...) | 每帧 onDraw 进入此路径 |
| 15 | hwui 源码 | Properties.cpp:68 + RenderProperties.h:563-564 | enableRenderEffectCache=true,命中条件含 imageFilter 指针 | cache 在首帧无救 |
| 16 | 业务源码 | NumStateViewAnimateExt.kt:372 | SHOW_ALPHA = 0.99f 注释”原生阴影在 alpha=1 时会扣除控件下方的阴影” | #4 saveLayer 的来源 |
| 17 | 业务源码 | NotificationNumStateView.kt:369-372 | forceHasOverlappingRendering(true) + alpha 0.99 | 完整闭环 NumStateView saveLayer |
9. 不确定项 & 后续验证
- kDynamicVKAllocateTrace 默认关闭:trace 没有抓到
promotedToLayer: mLayerProperties.mImageFilter的 ALOGE_AND_TRACE。调试构建中开启该 flag 可把”哪条分支让 view 升级 layer”打到 trace。 - IFaceMaskInteractor.maskState 的发射条件未读:建议补充确认是否在”点击展开数字态”以外的路径(例如下拉通知阴影、AOD 切换)也会触发同一动画。
- setFloatUniform 对 SkImageFilter.uniqueID / generationID 的影响:方案 B(effect 复用)的 CPU 路径收益取决于此。最坏情况退化到当前线上行为,无视觉风险。
- 方案 E(LinearGradient 替代)的浮点精度:数学上完全等价,但 Skia 在不同 BlendMode 下的着色精度可能有 < 1/255 差异,需 QA 截图逐像素对比。
- Vulkan VMA sub-allocate 阈值与 Skia scrap 池 budget:
GrResourceCache::setLimit当前配置未确证。如设备厂商把 budget 调得很低,“后续帧不再分配”的假设可能被打破。 - 方案 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。