Material Design 3 这套设计语言从 2021 年正式对外公布到现在,已经不算什么新鲜概念了,但真正在 Android 项目里把它落地到“能看、能用、能维护”的程度,我见过的团队并不多。大部分情况是:设计稿给的是 M3 风格,代码里还跑着 M2 的组件和一套自己写的颜色常量,最后做出来的东西既不像 M2 也不像 M3,配色割裂、组件行为不一致、深色模式一开就露馅。这篇内容就是把我自己在几个中大型 Android 项目里推行 M3 的完整过程拆开讲,从设计令牌怎么映射到代码、动态取色怎么取舍、组件怎么迁移、主题怎么组织,到实际踩过的坑和排查手法,全部摊开说。适合已经有一定 Android 开发基础、正在或准备把项目往 M3 迁移的开发者参考,也适合想搞清楚“M3 到底和 M2 差在哪、值不值得迁”的技术负责人。全文围绕 Material Design 3 实践展开,不空谈设计理念,只讲能直接抄作业的东西。
1. 迁移前的整体设计与思路拆解
1.1 为什么 M3 不能当成“换套颜色”来做
很多人对 M3 的第一印象是“圆角更大了、颜色更柔和了”,于是下意识把它理解成一次视觉换肤,觉得改改colors.xml、调调shapeAppearance就完事。这个判断是错的,而且错得很危险。M3 的本质变化在于它把颜色、字体、形状这三类视觉属性抽象成了设计令牌(Design Token)体系,并且引入了语义化命名和动态取色两个机制。这意味着 M2 时代那套“primary、primaryDark、accent”的命名逻辑在 M3 里根本对不上号,你如果硬套,最后会得到一堆语义混乱、在深色模式下无法自洽的颜色引用。
我举个具体的例子。M2 里colorPrimary通常被当成主品牌色,按钮、标题、选中态都用它。到了 M3,颜色被拆成了primary、onPrimary、primaryContainer、onPrimaryContainer四层,分别对应“主色”“主色上的内容色”“主色容器”“容器上的内容色”。一个填充按钮用的是primary背景配onPrimary文字,而一个 tonal 按钮用的是secondaryContainer配onSecondaryContainer。你要是还按 M2 的思路只维护一个主色,那这些组件根本没法正确渲染。所以迁移的第一步不是改代码,而是先把设计令牌和代码里的语义角色对齐,这是整个实践的骨架。
1.2 迁移策略的三种路线与取舍
实际项目里推行 M3,我总结下来无非三条路线,各有适用场景,选错了会给自己挖坑。
| 路线 | 做法 | 优点 | 代价 | 适用场景 |
|---|---|---|---|---|
| 全量替换 | 直接升级到 Material Components 1.9+ 或 Compose Material3,全面改用 M3 组件与主题 | 一致性最好,长期维护成本低 | 一次性改动大,回归测试范围广 | 新项目、或组件数量可控的中型项目 |
| 渐进共存 | 保留 M2 主题,局部页面用 M3 主题包裹 | 风险可控,可分批推进 | 两套主题并存,容易出现样式冲突 | 大型存量项目、迭代节奏紧的团队 |
| 仅换皮 | 只替换颜色和形状,组件仍用 M2 | 改动最小 | 组件行为与视觉不匹配,长期是技术债 | 不推荐,仅作临时过渡 |
我个人的建议是:新项目一律走全量替换,存量项目走渐进共存但要设定明确的收敛时间点。所谓“仅换皮”这条路,我试过一次,结果是半年后不得不推倒重来,因为 M2 组件的内部实现和 M3 的视觉规范对不上,比如 M2 的MaterialButton默认高度、内边距、状态层都和 M3 的 filled button 不一致,你光改颜色只会得到一个“穿着 M3 衣服的 M2 按钮”,越看越别扭。
1.3 主题分层:把颜色、字体、形状拆开管理
M3 的主题体系天然是分层的,我在项目里会严格按这个结构组织资源,避免所有东西堆在一个themes.xml里。
- 颜色层:定义
ColorScheme,包含 primary/secondary/tertiary/error 四组主色及其 container、on 系列,加上 surface 系列和 outline 系列。 - 字体层:定义
Typography,M3 有 display/headline/title/body/label 五组,每组三档(large/medium/small),共 15 个文本样式。 - 形状层:定义
Shapes,M3 有 extraSmall/small/medium/large/extraLarge 五档圆角。
这三层各自独立,组件只引用语义角色,不直接引用具体色值。这个约束听起来简单,但它是整个 M3 实践能不能长期维护的关键。我见过太多项目在colors.xml里写m3_primary = #6750A4,然后在布局里直接@color/m3_primary,一旦要做动态取色或者换主题,全部失效。正确的做法是组件引用?attr/colorPrimary这类主题属性,由主题在运行时决定具体值。
1.4 动态取色的取舍:不是所有 App 都该开
动态取色(Dynamic Color)是 M3 最有辨识度的能力之一,Android 12 及以上可以从用户壁纸提取色调生成配色方案。但我必须泼一盆冷水:动态取色不是默认就该开的。它适合内容消费类、工具类、系统风格强的 App,因为这类应用对品牌色的依赖弱,跟随系统反而显得协调。但品牌属性强的应用(比如有严格 VI 规范的金融、电商类),开了动态取色会导致品牌色被壁纸色覆盖,用户反而认不出你的 App。
我的处理方式是做成可配置开关:在设置里给用户一个“跟随系统主题色”的选项,默认关闭,品牌色作为兜底。代码层面通过DynamicColors.applyToActivitiesIfAvailable()控制,并且要处理好 Android 12 以下的降级逻辑——低版本没有动态取色能力,必须回落到静态配色方案,这个回落方案要单独设计并测试,不能想当然。
2. 核心细节解析与实操要点
2.1 颜色角色的映射关系与常见误用
M3 的颜色角色数量比 M2 多得多,初次接触容易懵。我把最常用的几组和它们的典型用途整理成表,方便对照。
| 颜色角色 | 典型用途 | 常见误用 |
|---|---|---|
| primary / onPrimary | 主要操作按钮、强调元素 | 拿 primary 当大面积背景,导致视觉过重 |
| primaryContainer / onPrimaryContainer | 次要强调、选中态背景、卡片高亮 | 和 primary 混用,层级不清 |
| secondaryContainer / onSecondaryContainer | tonal 按钮、筛选 chip 选中态 | 误当成主色使用 |
| surface / onSurface | 页面背景、正文文字 | 用纯白当 surface,深色模式失效 |
| surfaceVariant / onSurfaceVariant | 次级背景、辅助文字 | 和 surface 混用导致层次丢失 |
| outline / outlineVariant | 边框、分割线 | 用 onSurface 画分割线,对比过强 |
| error / onError | 错误提示、危险操作 | 用红色硬编码替代 error 角色 |
这里有个特别容易踩的坑:surface 系列在 M3 里不是简单的“背景色”。M3 引入了 surface container 的五个层级(lowest、low、default、high、highest),用来表达界面元素的抬升关系。如果你还按 M2 的思路只用一个colorSurface,那卡片、底部弹窗、对话框的层次感就出不来。我在项目里会明确约定:页面根背景用surface,卡片用surfaceContainerLow,底部弹窗用surfaceContainerHigh,对话框用surfaceContainerHighest,形成稳定的抬升语义。
2.2 字体系统的落地:15 个样式怎么用不混乱
M3 的 15 个文本样式如果无脑铺开,团队里每个人都会按自己喜好选,最后排版一片混乱。我的做法是在项目里再收一层,把 15 个样式映射到实际业务场景,形成团队约定。
- display 系列:只在极少数的空状态大标题、引导页使用,日常页面基本不用。
- headline 系列:页面主标题用
headlineSmall,区块标题用headlineMedium的情况很少,慎用。 - title 系列:卡片标题、列表项主文本用
titleMedium,对话框标题用headlineSmall或titleLarge。 - body 系列:正文用
bodyMedium,长文阅读用bodyLarge,辅助说明用bodySmall。 - label 系列:按钮文字用
labelLarge,标签、chip 用labelMedium,极小标注用labelSmall。
这个映射表要写进团队规范文档,Code Review 时对照检查。我吃过亏:早期没约定,结果同一个列表页里,列表项标题有人用titleMedium、有人用bodyLarge,视觉上差一截,用户看着就觉得“这 App 没做完”。
2.3 形状系统的参数选择与视觉一致性
M3 的五档圆角(extraSmall 4dp、small 8dp、medium 12dp、large 16dp、extraLarge 28dp)看起来简单,但实际用起来要克制。我的经验是:组件圆角必须从这五档里选,不允许自定义中间值。一旦允许 10dp、14dp 这种“差不多”的值出现,视觉一致性就崩了。
具体到组件:按钮用small或full(胶囊形),卡片用medium,底部弹窗顶部圆角用extraLarge,输入框用extraSmall或small。这里有个细节,M3 的 filled button 默认是胶囊形(完全圆角),如果你项目里习惯方角按钮,改成胶囊形会显得突兀,这时候要么统一改,要么在主题里覆盖 button 的 shape,但覆盖后要确保所有按钮一致,不能有的圆有的方。
2.4 组件迁移的优先级排序
存量项目迁移组件,不可能一口气全换。我通常按“视觉影响大、改动成本低”的原则排序,先做收益高的。
- 按钮类:
MaterialButton换成 M3 的 filled/tonal/outlined/text 四种变体,改动集中在样式,风险低收益高。 - 输入框:
TextInputLayout升级到 M3 样式,注意 outlined 和 filled 两种形态的选择。 - 卡片:
MaterialCardView调整圆角和 elevation,配合 surface container 层级。 - 顶部栏:
Toolbar迁移到MaterialToolbar或 Compose 的TopAppBar,注意滚动行为和颜色。 - 底部导航:
BottomNavigationView换成NavigationBar,这是 M3 变化较大的组件,需要重新设计交互。 - 对话框与弹窗:
MaterialAlertDialog、BottomSheetDialog调整圆角和背景色。
注意:
BottomNavigationView到NavigationBar的迁移不是简单替换类名,M3 的 NavigationBar 在选中态指示器、图标与文字布局、高度上都有变化,需要同步调整布局参数,否则会出现图标被裁切或文字重叠。
3. 实操过程与核心环节实现
3.1 依赖与主题基础配置
先确认依赖版本。View 体系用 Material Components 1.9.0 及以上,Compose 体系用androidx.compose.material3的稳定版。这两个体系的 M3 实现是独立的,不要混用主题资源。
// View 体系 implementation 'com.google.android.material:material:1.11.0' // Compose 体系 implementation platform('androidx.compose:compose-bom:2024.02.00') implementation 'androidx.compose.material3:material3'主题配置上,View 体系继承Theme.Material3.DayNight,Compose 体系用MaterialTheme包裹。我建议在themes.xml里只定义颜色和形状的覆盖,字体通过fontFamily统一指定,不要在主题里散落一堆textAppearance覆盖,那样后期根本维护不动。
<style name="Theme.App" parent="Theme.Material3.DayNight"> <item name="colorPrimary">@color/brand_primary</item> <item name="colorOnPrimary">@color/brand_on_primary</item> <item name="colorPrimaryContainer">@color/brand_primary_container</item> <item name="colorOnPrimaryContainer">@color/brand_on_primary_container</item> <!-- 其余角色同理,务必成组定义 --> </style>这里的关键是成组定义。primary 和 onPrimary 必须一起定义,container 和 onContainer 必须一起定义,缺一个就会导致某个组件在特定状态下文字看不清。我见过只定义了colorPrimary没定义colorOnPrimary的项目,结果按钮文字用了默认的黑色,在深色主色上几乎看不见。
3.2 静态配色方案的生成与校验
品牌色到 M3 配色方案的转换,手工调是调不准的,因为 M3 的色调系统基于 HCT 色彩空间,涉及色调(tone)的精确映射。我的做法是用官方的 Material Theme Builder 工具,输入品牌色,导出完整的配色方案,然后人工校验对比度。
校验的重点是对比度。M3 规范要求正文文字与背景的对比度至少 4.5:1,大号文字至少 3:1。工具导出的方案大部分情况下是合规的,但品牌色偏亮或偏暗时可能出问题。我一般会用对比度检查工具抽查几组关键组合:onPrimary对primary、onSurface对surface、onSurfaceVariant对surfaceVariant。实测下来,品牌色明度在中间区间(L 值 40 到 60)时最稳,太亮太暗都容易在某一组上翻车。
深色模式的配色方案要单独生成,不能简单把浅色方案反相。M3 的深色方案里,primary 通常比浅色模式更亮、饱和度更低,surface 是一系列带色调的深灰而不是纯黑。我见过直接把#FFFFFF换成#000000的做法,结果深色模式下所有 elevation 层次全部消失,卡片和背景糊成一片。
3.3 动态取色的接入与降级处理
动态取色的接入分两步:判断可用性和应用配色。
// 在 Application 或 BaseActivity 中 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { DynamicColors.applyToActivitiesIfAvailable(this) }这段代码看起来简单,但有几个细节必须处理。第一,applyToActivitiesIfAvailable要在super.onCreate之前调用,否则主题已经应用了静态配色,动态取色不生效。第二,如果用户关闭了“跟随系统主题色”开关,就不能调用这个方法,需要走静态配色。第三,动态取色生成的配色方案里,某些角色(比如品牌强调色)可能和你的品牌调性冲突,这时候可以用DynamicColorsOptions做角色级别的覆盖。
降级处理是重点。Android 12 以下没有动态取色,必须保证静态配色方案完整且视觉过关。我的做法是把静态方案当成“主方案”来打磨,动态取色当成“增强项”,这样即使大部分用户用不上动态取色,App 的视觉也不会差。反过来把动态取色当主方案、静态方案随便凑合的项目,在低版本设备上会非常难看。
3.4 组件样式的统一覆盖
M3 组件的默认样式不一定符合项目需求,需要在主题里统一覆盖。以按钮为例,如果项目要求所有 filled button 高度统一为 48dp、圆角用 small:
<style name="Widget.App.Button.Filled" parent="Widget.Material3.Button"> <item name="android:minHeight">48dp</item> <item name="shapeAppearance">@style/ShapeAppearance.App.Small</item> </style>然后在布局里通过style属性引用,或者在主题里用materialButtonStyle全局指定。全局指定是更推荐的做法,因为它能保证所有按钮默认一致,避免开发者忘记加 style。同理,materialCardViewStyle、textInputStyle、bottomNavigationStyle都可以在主题里统一配置。
这里有个实操心得:覆盖组件样式时,只覆盖必要的属性,不要整个重写。我见过把Widget.Material3.Button的所有属性都抄一遍再改几个值的做法,结果 Material Components 库升级后新增的属性没被继承,组件行为出现诡异问题。正确的做法是继承官方样式,只覆盖差异项。
3.5 Compose 与 View 混合项目的主题同步
现在很多项目是 Compose 和 View 混用的,这时候主题同步是个大问题。Compose 的MaterialTheme和 View 的Theme.Material3是两套独立的主题系统,颜色值需要手动对齐。
我的做法是单一数据源:在代码里定义一份颜色常量(或者从资源读取),同时喂给 Compose 的ColorScheme和 View 的主题属性。具体来说,Compose 侧构建lightColorScheme()/darkColorScheme()时用的色值,和colors.xml里的色值来自同一份定义,避免两边各写一套导致不一致。
val AppLightColorScheme = lightColorScheme( primary = BrandPrimary, onPrimary = BrandOnPrimary, primaryContainer = BrandPrimaryContainer, onPrimaryContainer = BrandOnPrimaryContainer, // ... )View 侧则通过colors.xml引用同名色值。这个同步工作看起来繁琐,但它是混合项目视觉一致的前提。我踩过的坑是:Compose 页面和 View 页面并排显示时,同一个按钮颜色差了一点点,排查半天才发现是两边色值定义不同步。
4. 常见问题与排查技巧实录
4.1 深色模式下颜色错乱的排查路径
深色模式是 M3 实践里问题最集中的地方。典型症状是:切到深色模式后,某些文字看不见、某些卡片背景和页面背景一个色、某些图标消失。排查我一般按这个顺序走。
先查是否成组定义了颜色角色。如果只定义了colorSurface没定义colorOnSurface,深色模式下 onSurface 会回落到默认值,很可能和 surface 撞色。再查是否用了硬编码色值。布局里写android:textColor="#000000"的地方,深色模式下必然是黑字黑底。最后查是否用了 M2 的旧属性。colorPrimaryDark在 M3 里已经废弃,如果还在用,深色模式行为不可预期。
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 文字看不见 | on 系列颜色未定义或硬编码 | 全局搜索硬编码色值,检查主题属性完整性 |
| 卡片与背景同色 | surface container 层级未区分 | 检查卡片是否用了 surfaceContainerLow 等层级 |
| 图标消失 | tint 用了固定色或未跟随主题 | 检查 ImageView 的 tint 是否引用主题属性 |
| 状态栏颜色不对 | 未处理系统栏配色 | 检查 WindowInsets 和状态栏图标明暗设置 |
4.2 动态取色不生效的几种情况
动态取色调了没反应,我遇到过三种原因。第一种是调用时机不对,applyToActivitiesIfAvailable在super.onCreate之后调用,主题已应用,自然不生效。第二种是设备或系统版本不支持,Android 12 以下直接跳过,这个要靠日志确认。第三种是主题本身覆盖了颜色属性,动态取色生成的颜色被主题里的静态定义覆盖了。第三种最隐蔽,因为代码逻辑没错,但视觉上就是没变。
排查第三种的方法是:临时把主题里的颜色覆盖全部注释掉,看动态取色是否生效。如果生效了,说明是覆盖冲突,需要调整覆盖策略——要么去掉覆盖,要么用DynamicColorsOptions做角色级覆盖而不是主题级覆盖。
4.3 组件升级后的布局错位问题
从 M2 组件升级到 M3 组件,布局错位几乎是必然的,因为 M3 组件的默认尺寸、内边距、最小高度都变了。最典型的是按钮:M2 的MaterialButton默认最小高度 48dp,M3 的 filled button 也是 48dp,但内边距和文字大小变了,导致按钮宽度变化,原本排在一行的按钮可能换行。
我的处理方式是升级一个组件,回归一个页面,不要批量升级后统一测。批量升级看似高效,但出问题时很难定位是哪个组件的改动导致的。逐个升级、逐个页面回归,虽然慢,但问题定位快,整体返工少。另外,升级前先把关键页面的截图存档,升级后对比,能快速发现视觉差异。
4.4 主题属性引用失效的隐蔽原因
有时候明明主题里定义了colorPrimary,组件却拿不到,显示成默认色。这种情况我遇到过两次,原因都很隐蔽。一次是主题继承链断了:自定义主题继承了Theme.Material3.DayNight,但中间某一层又继承了别的主题,导致属性被覆盖。另一次是values-night目录下的主题定义不完整,只覆盖了部分属性,深色模式下缺失的属性回落到默认值。
排查这类问题,我习惯用adb shell dumpsys activity top看当前 Activity 实际应用的主题,或者临时在代码里打印theme.resolveAttribute()的结果,确认属性是否真的解析到了预期值。这个方法比盲猜快得多。
4.5 常见问题速查表
| 问题 | 排查方向 | 解决要点 |
|---|---|---|
| 按钮颜色不对 | 检查 primary/onPrimary 是否成组定义 | 补齐颜色角色,避免硬编码 |
| 深色模式文字看不清 | 检查 on 系列和硬编码色值 | 全部改用主题属性引用 |
| 动态取色无效 | 检查调用时机和主题覆盖 | 调整调用顺序,用角色级覆盖 |
| 组件圆角不一致 | 检查是否用了非标准圆角值 | 统一从五档形状里选 |
| 字体层级混乱 | 检查是否按映射表使用 | 建立团队文本样式约定 |
| 混合项目颜色不一致 | 检查 Compose 与 View 色值来源 | 统一单一数据源 |
5. 落地后的维护与团队协作建议
5.1 建立设计令牌的单一数据源
M3 实践能不能长期维护,核心在于有没有单一数据源。我的做法是在项目里建一个designsystem模块或者至少一个专门的包,集中管理颜色、字体、形状的定义。所有页面、组件都从这里引用,不允许在业务代码里出现色值字面量。
这个约束要靠工具保障,不能只靠自觉。我会在 CI 里加一条静态检查,扫描业务代码里是否有#开头的色值字面量,有就报错。这条规则一开始会让团队不适应,但坚持下来后,换主题、调色值的成本会大幅降低。实测下来,一个中等规模项目从“色值散落”收敛到“单一数据源”,大概需要两到三轮迭代,之后维护效率提升非常明显。
5.2 组件封装与业务解耦
M3 组件直接用,业务代码里会散落大量样式配置。我的建议是对高频组件做一层薄封装,比如AppButton、AppCard、AppTextField,内部固定 M3 的样式选择,对外只暴露业务需要的参数。这样业务代码不用关心用的是 filled 还是 tonal,视觉一致性由封装层保证。
封装要克制,不要过度设计。我见过把 M3 组件封装成一套自研组件库的做法,结果 Material Components 升级后,封装层和底层组件行为不一致,维护成本翻倍。薄封装的原则是:只封装样式和常用组合,不改变组件行为,不隐藏组件能力。
5.3 渐进迁移的节奏控制
存量项目迁移 M3,节奏控制比技术方案更重要。我的经验是按“主题先行、组件分批、页面收尾”的顺序推进。先把主题和设计令牌搭好,这是地基;然后按组件优先级分批迁移,每批迁移后做回归;最后按页面收尾,确保每个页面都符合 M3 规范。
每个批次要有明确的验收标准,比如“本批次迁移的按钮在所有页面视觉一致、深色模式正常、动态取色开关切换无异常”。没有验收标准的迁移,最后一定会留下半成品页面。我在一个项目里就因为赶进度跳过了验收,结果三个月后用户反馈某个页面的按钮在深色模式下是白底白字,排查发现是迁移时漏了那个页面的样式覆盖。
5.4 设计稿与代码的对照机制
设计和开发之间的 M3 理解偏差,是实践里最常见的摩擦点。设计师给的是 Figma 里的 M3 组件,开发实现的是 Material Components 的 M3 组件,两者在细节上可能有差异,比如圆角、内边距、状态层透明度。我的做法是建立一份对照表,把设计稿里的关键组件和代码里的实现一一对应,标注差异点,双方确认后再开发。
这份对照表不需要很正式,一个共享文档就行,但必须维护。我见过设计和开发各干各的,最后验收时发现按钮圆角差了 2dp、卡片阴影深浅不一,返工成本很高。提前对照,这些问题在开发阶段就能消除。
6. 我个人在实际操作中的几点体会
M3 这套体系刚上手时确实比 M2 繁琐,颜色角色多、组件变体多、约束多,但用熟之后会发现它的约束恰恰是它的价值——语义化的颜色角色让深色模式和动态取色成为可能,分层的主题结构让换肤和品牌定制变得可控。我最大的体会是:不要试图绕过 M3 的约束去“简化”它,那些看起来麻烦的成组定义、语义引用,恰恰是后期维护省心的前提。
另一个体会是关于动态取色。它很吸引眼球,但不要为了用而用。先想清楚你的 App 是不是真的适合跟随系统配色,想不清楚就先关着,静态方案打磨好一样能做出漂亮的 M3 界面。我见过为了展示动态取色而强行开启、结果品牌识别度下降的项目,得不偿失。
最后分享一个小技巧:迁移过程中,把每个页面的浅色和深色截图都存一份,按页面归档。这个截图库在回归测试、设计验收、问题排查时都能派上用场,尤其是深色模式的问题,光看代码很难发现,对比截图一目了然。这个习惯我从第一个 M3 项目保持到现在,省下的排查时间远超维护截图库的成本。