先把结论放在前面: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-交互与缓存策略
有任何问题欢迎在评论区留言交流。