第168篇 阴影与海拔:elevation 的绘制代价

简介: Android中elevation非单纯视觉参数,而是三位一体机制:Z轴绘制顺序、阴影轮廓来源、Material表面分层依据。其核心“拐点”在于性能成本叠加——轮廓计算、离屏渲染与模糊采样。M3推荐用tonalElevation(色调分层)替代投影,兼顾性能与深色模式可读性。

先把结论放在前面:elevation 在 Android 里不是一个"视觉参数",而是三件合一的机制——Z 轴绘制顺序、阴影轮廓的来源、以及 Material 主题里 surface 分层的依据。 这题答得浅的人说"用 elevation 属性控制阴影";答得深的人会说"阴影的成本在轮廓计算与离屏渲染,且 M3 用 surfaceContainer 亮度梯度替代了大部分投影"。

"拐点"指的是从"能用"到"该换方案"的那个判定点。 能说出拐点在哪、怎么量,才算深入。

elevation 的三段机制

环节 elevation 起的作用 关键点
绘制顺序 决定 Z 轴排序(同父容器内,值大者绘制在上面) 值相等时按 XML 顺序决定,不是稳定规则
阴影生成 由 View 的 outline 决定轮廓形状 outline 为 null 时不画阴影
主题分层 Material 主题里 surface 分层与组件默认 elevation 有规范值 组件默认阴影是设计的一部分,改它会破坏层级语言

Compose 侧对应的三个 Modifier 要分清:

// ① shadow:真实阴影,需要 outline
Box(
    Modifier
        .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp))
        .background(Color.White)
)

// ② graphicsLayer:直接控制绘制层,不产生阴影
Box(Modifier.graphicsLayer {
    shadowElevation = 8f })

// ③ surface + tonalElevation:M3 用色调阴影表达层级
Surface(
    tonalElevation = 3.dp,        // 3dp 叠加一层 surfaceTint 色,不画投影
    shadowElevation = 0.dp,
    shape = RoundedCornerShape(12.dp)
)

tonalElevation 与 shadowElevation 是这题的拐点。 M3 默认组件大多只给 tonalElevation,不给投影——因为它用"在 surface 上叠加一层品牌色"来表达层级,没有离屏渲染、没有模糊半径计算,性能友好,且在深色模式下依然可读。要真正画出投影时才需要 shadowElevation,也就是要付轮廓计算与可能的离屏开销。

成本在哪里:拐点的判据

阴影的成本有三块,面试要能逐块说:

成本一:轮廓计算。 每帧都要算 View 的 outline。圆角矩形便宜;clipPath 裁出来的任意形状、含 Path 的自定义 onDraw、需要 setShadowLayer 的文本与 Bitmap,都需要额外的路径计算或软件层绘制。

成本二:离屏渲染(offscreen layer)。 当 View 需要绘制投影时,框架要先把它渲染到一张离屏图层,再把图层按 alpha 与模糊结果绘制到父层。离屏意味着这块区域的绘制开销翻倍(一次画原内容、一次画投影),并且离屏图层的尺寸会随内容变化重新分配。

成本三:模糊半径越大,采样与缩放成本越高。 Android 用缓存的模糊位图近似,所以大 elevation 在长列表里滚动时会持续触发模糊贴图的重建。

拐点判据(可直接写进规范):

场景 判据 处理
单个静止卡片 / 对话框 elevation ≤ 24dp 且不频繁变化 直接用 shadow
长列表项 每帧都可能有子项进入视口 用 surface 的 tonalElevation,或预渲染阴影图
高频动画(跟随手指移动) 阴影每帧重算 用固定阴影图 + translationZ 位移,或干脆用描边
层级超过 5 层 阴影已无法表达清晰层级 收敛层级,改用 surface 分层

这张表就是"拐点"的答案:不是阴影不能用,而是当"每帧重算 + 离屏 + 大模糊"三者叠加时就该换方案。 面试答到这一层,通常就过关了。

最常见的坑是

第一层坑:列表项全带大阴影,中端机滚动帧率明显下降。

现场表现是:列表滑动时中端机(骁龙 6xx 级别)掉到 50 帧上下,dumpsys gfxinfo 里 Draw 与 Prepare 耗时明显升高。原因是每个 item 都有 elevation,视口内 item 频繁创建与回收,每帧都要重算轮廓并重建离屏图层。规范是列表项默认零阴影,靠 surfaceContainer 分层或一条 divider 表达结构;确有悬浮效果时用 itemAnimator 期间的短暂阴影,或预渲染一张阴影图做九宫格拉伸。

第二层坑:给 View 设了 elevation 却看不到阴影,排查半天。

三个常见原因:① outline 为 null——自定义 View 没重写 getOutline(),ViewOutlineProvider 拿不到轮廓,此时设再大的 elevation 也无效果;② 父容器或自身设置了会裁剪的 clipToOutline/clipToPadding,或被 clipPath 限制;③ M3 主题下用了 Surface 但只设了 shadowElevation 没给形状,shape 为 null 时同样没有轮廓。规范是自定义 View 要重写 getOutline() 并用 setOutlineProvider 绑定;规范上给阴影的同时把形状一起写进代码,不依赖主题默认。

第三层坑:在深色模式下仍靠阴影表达层级,结果边界糊成一团。

暗色下阴影几乎不可见(第165、164篇都讲过),继续用投影表达层级就会出现"卡片和背景糊在一起、看不出谁浮在谁上面"。规范是深色模式下用 surfaceContainer 五档亮度梯度 + 一层极低对比度描边表达层级,shadowElevation 归零;浅色模式再把投影加回来。

这题在面试里怎么问、怎么答

问:elevation 到底做了什么?

答:三件事——决定同父容器内的绘制顺序(Z 轴)、提供阴影轮廓的来源(View 的 outline)、以及作为 Material 组件默认层级的规范值。补一句"outline 为 null 时不画阴影",这是自定义 View 场景最常见的失分点。

问:shadow 和 surface 的 tonalElevation 有什么区别?什么时候用哪个?

答:shadow(或 shadowElevation)产生真实投影,成本在轮廓计算与离屏渲染,但层级表达最强;tonalElevation 是在 surface 上叠加一层 surfaceTint 色,靠颜色深浅表达层级,不产生投影,性能友好、深色模式下也可读,是 M3 组件的默认做法。判据是静态、数量少、需要强层级感用投影;列表、高频动画、深色模式用色调分层。

问:为什么滚动列表里的阴影这么贵?怎么量?

答:滚动时视口内 item 频繁创建回收,每帧都可能触发轮廓重算与离屏图层重建;大 elevation 的模糊需要采样并重建缩放后的模糊贴图,长列表里这些成本叠加就会掉帧。量法有三条:① dumpsys gfxinfo <包名> 看 Draw/Prepare/Process 的耗时分布;② Android Studio 的 GPU Profile / System Trace 看 RenderThread 的时间线;③ 真机实测 P95 帧耗时,而不是只看平均帧率——平均值会被少量掉帧掩盖,P95 才能暴露滚动卡顿。

问:自定义 View 要支持阴影,怎么做?

答:重写 getOutline()(或构造时 setOutlineProvider),根据当前状态设置 Outline.setRoundRect/setOval/setPath;关键是 outline 要随状态变化而刷新,例如圆角半径随进度变化时需要 invalidateOutline()。同时要注意自定义 onDraw 里 canvas.drawPath 并不会自动产生 outline——投影是基于 outline 生成的,不是基于绘制内容的。

问:M3 里怎么表达层级?为什么不继续用阴影?

答:用 surfaceDim → surface → surfaceContainerLow → surfaceContainer → surfaceContainerHigh → surfaceContainerHighest 六档表面亮度梯度。原因是:① 投影在大 elevation 下性能代价高;② 暗色模式下投影几乎不可见,亮度梯度是唯一在两种模式下都可靠的语言;③ 色调分层能与品牌色统一,投影只能是黑色透明度。规范是浅色模式两者可叠加,深色模式只用亮度梯度。

落到项目里怎么做

一条能写进规范的红线:长列表内的子项默认 elevation = 0,层级靠 surface 分层或 1dp 分割线表达;确需悬浮的浮层(对话框、抽屉、悬浮按钮)才允许使用阴影,且阴影档位从组件库统一取值,不得在业务代码里随手写数值。 这一条能直接压掉长列表的帧率问题。

配套实践三条:① 给阴影档位建一组 token(如 elevation/rest/raised/overlay 四个语义档),组件只引用档位不引用数值,便于统一调优与暗色模式整体降级;② 把阴影纳入性能回归:在中端机真机上滚动长列表,看 P95 帧耗时;③ 自定义 View 提供阴影能力时,把 outline 作为接口的一部分设计——在基类里统一处理 invalidateOutline 的时机,避免每个子类重复实现。

给正在准备面试的你

这题的答法要落到"elevation 是三合一机制,拐点在成本叠加"这条主线。推荐这条线:先讲 elevation 的三段机制(顺序 / 轮廓 / 主题分层)→ 讲 shadow 与 tonalElevation 的分工 → 讲成本三块(轮廓计算、离屏渲染、模糊采样)→ 讲拐点判据表 → 补三个坑:列表大阴影、outline 为 null、深色下仍靠投影 → 收在"列表项零阴影 + 阴影 token"。能被追问到"设了 elevation 为什么没阴影"并答出"outline 为 null,需要重写 getOutline",说明真的自定义过 View;能主动说出 P95 帧耗时而不是平均帧率,说明做过性能验证。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:ConstraintLayout-进阶:Barrier、Group-与辅助线

下一篇预告:WebView-使用与安全:JS-交互与缓存策略

有任何问题欢迎在评论区留言交流。

相关文章
|
并行计算 Java 数据处理
SpringBoot高级并发实践:自定义线程池与@Async异步调用深度解析
SpringBoot高级并发实践:自定义线程池与@Async异步调用深度解析
1302 0
|
存储 编译器 Kotlin
Compose 类型稳定性注解:@Stable & @Immutable
Compose 类型稳定性注解:@Stable & @Immutable
556 1
|
Web App开发 前端开发 Java
SpringBoot默认200个线程对于Websocket长连接够用吗?(一)
上篇推文从源码剖析SpringBoot中Tomcat的默认最大连接数中我们知道,SpringBoot的内嵌Tomcat默认的最大连接数为200。那么,这个默认值对于项目中引入了WebSocket使用长连接后,是否足够用呢?今天强哥就带大家一起从源码的角度来分析一下。
SpringBoot默认200个线程对于Websocket长连接够用吗?(一)
|
存储 缓存 Android开发
android 读取WebView缓存及清理WebView缓存
1.缓存的分类: 首先要说的一点是缓存的分类,我们缓存的数据分为:页面缓存和数据缓存 页面缓存:加载一个网页时的html、JS、CSS等页面或者资源数据,这些缓存资源是由于浏览器  的行为而产生,开发者只能通过配置HTTP响应头影响浏览器的行为才能间接地影响到这些缓存数据。
4163 0
|
Android开发
【Android 安装包优化】动态库打包配置 ( “armeabi-v7a“, “arm64-v8a“, “x86“, “x86_64“ APK 打包 CPU 指令集配置 | NDK 完整配置参考 )
【Android 安装包优化】动态库打包配置 ( “armeabi-v7a“, “arm64-v8a“, “x86“, “x86_64“ APK 打包 CPU 指令集配置 | NDK 完整配置参考 )
2532 0
【Android 安装包优化】动态库打包配置 ( “armeabi-v7a“, “arm64-v8a“, “x86“, “x86_64“ APK 打包 CPU 指令集配置 | NDK 完整配置参考 )
|
Android开发
AS错误:Duplicate class kotlin.xxx.jdk8.DurationConversionsJDK8Kt found in modules kotlin-stdlib-1.8.22
本文描述了Android Studio中遇到的"Duplicate class found in modules"错误的解决方法,通过在`app/build.gradle`文件中使用`constraints`来排除过时的kotlin-stdlib-jdk7和kotlin-stdlib-jdk8依赖,解决了依赖冲突问题。
1729 1
|
Android开发
Android 如何将定制的Launcher成为系统中唯一的Launcher
Android 如何将定制的Launcher成为系统中唯一的Launcher
702 2
|
缓存 JavaScript 安全
MethodHandle方法句柄使用分享
JDK 1.7 引入了 `MethodHandle` 类,优化了之前的反射机制性能问题。`MethodHandle` 提供了一种更高效的方法调用机制,通过 `MethodType` 和 `Lookup` 对象实现。具体流程包括:创建 `MethodType` 获取方法签名,通过 `Lookup` 查找方法对应的 `MethodHandle`,最后调用方法。此外,通过 `ConstantCallSite` 进一步优化性能,利用其不变性特点减少运行时开销。实验结果显示,使用 `ConstantCallSite` 显著提升了方法调用速度。
512 3
MethodHandle方法句柄使用分享
|
缓存 运维 监控
【运维必备知识】Linux系统平均负载与top、uptime命令详解
系统平均负载是衡量Linux服务器性能的关键指标之一。通过使用 `top`和 `uptime`命令,可以实时监控系统的负载情况,帮助运维人员及时发现并解决潜在问题。理解这些工具的输出和意义是确保系统稳定运行的基础。希望本文对Linux系统平均负载及相关命令的详细解析能帮助您更好地进行系统运维和性能优化。
1689 3

热门文章

最新文章