先把结论放在前面:Material 3 的核心不是"新控件",而是两套设计系统的换轨——色彩从"固定色值"换成"语义角色",形状从"硬编码圆角"换成"shape scale",动效从"自定义时长"换成"统一 motion scheme"。 动态取色只是色彩轨道上新增的一个可选输入源。这题真正要答的是:换轨之后,视觉一致性靠什么保证,以及代价付在哪里。
这题能把"看过 Material 3 文档"和"在项目里切过主题"区分开:看过的人能说出 M2 有哪些控件,看过的人说不出"色板是怎么生成的";切过的人知道哪些场景会掉色、哪些坑是自动挡不住的。
色彩模型:从 hex 到语义角色
M3 的颜色系统分三层,面试要按层说:
| 层级 | 数量 | 作用 | 典型用途 |
|---|---|---|---|
| 基础色板(Primary / Secondary / Tertiary 等) | 少量关键色相 | 定义色彩基调 | 由取色器或设计给定 |
| 语义角色(surface / onSurface / surfaceVariant / outline…) | 约 30 个 | 表达"这个像素承担什么职责" | 组件只引用角色名 |
| 具体色值 | 每角色一组明/暗值 | 落到像素 | values/colors.xml + values-night |
核心思想是组件只引用语义角色,不引用具体色值。 colorScheme.primary 这样的写法,换主题、换品牌、换动态色都不用改组件代码。M2 虽然也有 colorPrimary 之类的条目,但语义覆盖不全——onPrimary、surfaceVariant、outlineVariant 这些能表达"文字在主色上应该用什么色"的角色,M2 是缺的,所以 M2 时代组件经常要写 ?attr/colorPrimary 加一个 textColorPrimary 的手工配对,这类配对一旦漏改就出现"主色按钮上的文字看不清"。
M3 的角色分两组:content 类(primary/secondary/tertiary + 各自的 on 角色、inverse 系列)与 surface 类(surface、surfaceDim、surfaceBright、surfaceContainerLowest 到 Highest 五档、surfaceVariant、outline、outlineVariant)。五档 container 是 M3 新增的层级,用来表达"这张卡片浮在第几层"——这是替代阴影做深度分层的主要手段,后面第168篇会专门讲两者的取舍。
动态取色:可用性优先于品牌
动态取色(Material You)在 Android 12 及以上可用,链路是三步:
// Android 12+:从系统壁纸提取色板,生成 MaterialTheme 的色板
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colorScheme = if (dynamicColor) {
dynamicLightColorScheme(context) // 或 dynamicDarkColorScheme(context)
} else {
LightColorScheme( // 兜底:品牌静态色板
primary = Color(0xFF00658E),
onPrimary = Color.White,
surfaceVariant = Color(0xFFDBE4F0)
)
}
// 系统设置里用户若关闭"取色",返回 null,必须有兜底分支
val seed = dynamicScheme ?: staticScheme
MaterialTheme(colorScheme = seed) {
/* 页面 */ }
三个必须讲对的细节:① 取色器可能返回 null(用户未授权壁纸取色、或厂商裁剪),所以必须有静态兜底;② dynamicLightColorScheme 在低版本上直接抛异常,所以要有 Build.VERSION.SDK_INT 判断;③ 动态色只影响 M3 语义角色,不影响硬编码的颜色——如果项目里还有一堆 #FF6200 这类写死的色值,开启动态取色后它们不会变,视觉上就出现"半新半旧",这是团队最常抱怨的点。
规范是:接入动态取色时,同批把写死的颜色清成语义角色,或至少登记成"暂不处理"的例外清单。 这句话能体现工程判断力。
shape scale 与 motion scheme:另外两轨
形状轨用 shape scale 统一:extraSmall(4dp) / small(8dp) / medium(12dp) / large(16dp) / extraLarge(28dp),通过 Shapes(...) 注入,组件(如 Card、Button、Dialog)引用 MaterialTheme.shapes.medium。好处是圆角从一个项目的几十处散值变成五个语义档;代价是设计稿里五花八门的圆角需要被归类,归不进去的要么改设计要么走自定义 shape,这一步在项目里往往比切颜色更难推进。
动效轨用 motion scheme 统一:emphasized(长时长的强调动效)、emphasizedDecelerate/Accelerate、standard(短时长)四档 easing 与两档 duration。Compose 里对应 MotionScheme 与 MaterialTheme 的 LocalMotionScheme(或 Material 组件内置的 motion token)。这条轨的价值在 M2 项目里体现得最明显——M2 时代每个页面各自定义 200ms/300ms/500ms,导致同一动作在不同页面手感不同,这类不一致用户说不出来但能感觉到。
选型的代价:把权衡摆到台面上
M2 与 M3 混用是真实存在的过渡态,面试如果问"有什么优劣",答案要列清楚:
| 维度 | M2 | M3 | 混用的代价 |
|---|---|---|---|
| 控件形态 | 传统 Material Design | 胶囊按钮、圆角卡片、FAB 变体 | 同一页面两种形态并存,视觉断裂 |
| 色彩 | attr + 手工 on 配对 | 语义角色 + 明暗成对 | 暗色下 M2 部分控件对比度不达标 |
| 动态取色 | 不支持 | 支持 | 混用页面无法跟随取色 |
| 依赖 | appcompat | material3 | 需评估依赖体积与冲突 |
| 无障碍 | 基础 | 对比度分级 + 色调辅助 | 混用页面需单独做对比度校验 |
混用的具体代价有三个可验证的点:① 行为不一致——M2 的 Snackbar 与 M3 的 Snackbar 在时长与 dismiss 行为上不同,同一页面混用会让交互不一致;② 主题穿透失败——M2 组件读 colorPrimary,动态取色改的是 M3 的 primary,于是老组件保持旧色;③ 阴影与层级体系冲突——M3 用 surfaceContainer 分层,老卡片继续用 elevation,两套深度语言并存。
最常见的坑是
第一层坑:接了动态取色,但老页面用 M2 组件和写死色值,结果"半新半旧"。
现场表现很典型:首页是 M3 写的主题,动态壁纸变成绿色,新按钮变绿,但列表 item 的分隔线、标题旁的标签、图表色板纹丝不动,整体看起来像两套设计拼在一起。根因是动态取色只驱动 M3 的角色色,M2 attr 与硬编码色不在链路里。规范是以模块为单位切,不要半模块切——一次把一个完整业务模块的 M2 组件替换完,再开下一个。
第二层坑:暗色模式下对比度不达标,只靠肉眼检查。
M3 的角色色是为暗色单独定义的,但自定义语义角色(比如项目自己加的 brandOnSurface、chartSeries1)通常只写了一组值,暗色下直接复用亮色值,就会出现深灰字压深色底。规范是新增语义角色必须成对提供明暗两套值,并把对比度校验做成 CI 卡点(对关键色对跑 WCAG 对比度计算,低于阈值就失败),而不是靠设计走查。
第三层坑:为适配圆角归类,把设计稿整体推翻重来,拖慢迁移。
形状轨推不动,常见原因是团队试图一次性统一全站圆角。规范是先在新页面用 shape scale,老页面按需迁移,允许过渡期存在两种圆角;用 Shapes() 注入的方式保证了将来只要改一处就能全站生效,迁移节奏可以完全由业务排期决定。
这题在面试里怎么问、怎么答
问:M3 的色彩系统是怎么设计的?为什么不直接给一套固定色值?
答:分三层——基础色板定基调,语义角色(content 类 + surface 类)表达像素职责,具体色值是角色在明暗两种模式下的取值。组件只引用语义角色,好处是换品牌、换明暗、接动态取色都不需要改组件代码;直接给固定色值则每个组件都要自己挑色,换主题时无从下手。再补 surface 五档 container 是 M3 用层级替代阴影做深度分层的关键,能答到这一层说明不是只看文档。
问:动态取色的完整链路是什么?有哪些失败路径?
答:Android 12+ 提供壁纸取色 API,Compose 侧 dynamicLightColorScheme/dynamicDarkColorScheme 生成色板,注入 MaterialTheme。失败路径有三条:① 取色器返回 null(未授权或厂商裁剪),必须静态兜底;② 低版本调用会抛异常,需要 Build.VERSION.SDK_INT 判断;③ 硬编码色值与 M2 attr 不跟随动态色变化,造成半新半旧,需要同步做语义角色收敛。
问:M2 与 M3 混用会有哪些问题?怎么控制?
答:四类问题——控件形态并存导致视觉断裂、暗色下 M2 组件对比度不足、动态取色只影响 M3 角色导致老页面颜色不变、依赖与资源体积上升。控制手段是按模块灰度切、以完整模块为单位、不做半迁移,并把 M2 组件的替换进度做成可度量的清单(按页面统计剩余 M2 组件数),比口头推进有效得多。
问:shape scale 和 motion scheme 解决了什么老问题?
答:形状轨把散落的圆角值收敛成五档语义档,组件通过 Shapes 注入,改一处即全站生效,同时让不同页面的圆角有内在节奏;动效轨把时长与曲线收敛成标准/强调两套 token,消除"同一个动作在不同页面手感不同"的隐性不一致。但要主动说代价:这两轨要求设计与实现一起归类,迁移期两种视觉并存是常态,需要排期而不是一次性切换。
落到项目里怎么做
一条能写进规范的红线:新增颜色只允许定义语义角色,且必须成对提供明暗两套值并通过对比度校验;写死色值与 colorPrimary 这类 M2 attr 一律不进 PR。 这条能直接决定动态取色能不能落地。
配套实践三条:① 在 Debug 构建里加一个"主题检查"浮层,实时显示当前 M2 attr 与 M3 角色的对照结果与当前色值的明暗模式来源,帮助排查不跟随的问题;② 把对比度校验写进构建脚本,对亮暗两套主题的关键色对逐一对账,低于阈值直接失败;③ 给动态取色加一条灰度开关,可按渠道或按用户分组开启,出问题时能立刻切回静态色板而无需发版。
给正在准备面试的你
这题的答法要落到"M3 是设计系统的换轨,动态取色只是色彩轨上的一个输入源"这条主线。推荐这条线:先讲三层色彩模型(色板 / 语义角色 / 具体色值)→ 讲 surface 五档 container 是深度分层的关键 → 讲动态取色的三条失败路径与静态兜底 → 讲 shape scale 与 motion scheme 另外两轨 → 摆出 M2/M3 混用的四类代价 → 收在"以完整模块为单位切"。能被追问到"接了动态取色为什么还有页面不变色"并答出"M2 attr 与硬编码色不在动态链路里",说明真的在项目里推过;能主动给出按模块灰度与对比度 CI 卡点两条工程手段,说明不止停在概念层。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Compose-与-View-互操作:混编的边界
下一篇预告:深色模式适配:资源与主题的双轨制
有任何问题欢迎在评论区留言交流。