Android Material Design 3 迁移实战:主题、动态取色与组件定制

发布时间:2026/10/29 3:05:20
Android Material Design 3 迁移实战:主题、动态取色与组件定制

Android 项目从 Material Design 2 迁到 Material Design 3,最直观的感受不是"界面变好看了",而是"以前写死的一堆颜色和圆角全得重新想一遍"。我前后在两个中型项目里做过完整的 M3 落地,第一个项目踩的坑最多——主题属性对不上、动态取色在低版本上直接崩、组件默认样式和设计稿差了一大截。这篇就把 Material Design 3 在 Android 上的实践路径完整拆一遍,从设计令牌怎么映射到代码、主题体系怎么搭、动态取色怎么安全降级,到组件定制和迁移排错,尽量把每一步"为什么这么做"讲清楚。适合已经熟悉 Android 基础开发、准备或正在做 M3 迁移的同行参考,也适合想搞明白 M3 那套颜色系统到底怎么运转的读者。

1. 先搞清楚 M3 到底改了什么,再动手改代码

很多人一上来就换依赖、改主题,结果改到一半发现整个颜色体系对不上,只能推倒重来。M3 和 M2 的差异不是几个属性名变了,而是底层设计模型换了。动手之前先把这套模型吃透,后面所有代码才有落脚点。

1.1 从"主色+强调色"到"角色化色彩系统"

M2 的色彩模型相对简单:一个colorPrimary、一个colorSecondary,再加若干变体,本质上是以"品牌色"为中心往外派生。M3 换了一套思路,它把颜色按**角色(role)**划分,每个角色都有明确的语义和使用场景,核心是这么几组:

  • Primary / On Primary / Primary Container / On Primary Container:主色调及其容器色,容器色用于卡片、选中态这类"弱化版主色"场景。
  • Secondary / Tertiary:辅助色和第三色,用来做视觉分层,避免整个界面只有一种色调。
  • Surface / Surface Variant / Surface Container 系列:表面色,M3 里 surface 被拆成了多个层级(container lowest 到 highest),用来表达海拔和层次。
  • Outline / Outline Variant:描边色,用于分割线和边框。
  • Error / On Error:错误态。

关键在于每个"底色"都配了一个On前缀的前景色。On Primary就是放在Primary上的文字/图标颜色,保证对比度达标。这套配对关系是 M3 可访问性的基础,你不能随便拿Primary当背景又用Secondary当文字,对比度很可能不达标。

我第一个项目就犯过这个错:设计稿里有个按钮是浅色背景配深色文字,我图省事直接用了colorPrimary当背景、colorOnSurface当文字,结果在深色模式下文字几乎看不见。正确做法是用colorPrimaryContainer+colorOnPrimaryContainer这对组合,它们在明暗两套主题下都保证对比度。

1.2 设计令牌到代码属性的映射关系

设计侧给的往往是一套 design token(设计令牌),比如md.sys.color.primary、md.sys.shape.corner.medium。落到 Android 代码里,这些令牌对应到主题属性。下面这张表是我实际项目里整理的核心映射,迁移时对着查能省很多时间:

设计令牌(示例)Android 主题属性典型用途
md.sys.color.primarycolorPrimary主按钮、FAB、选中态
md.sys.color.on-primarycolorOnPrimary主色上的文字图标
md.sys.color.primary-containercolorPrimaryContainer卡片、Chip 选中背景
md.sys.color.surfacecolorSurface页面背景
md.sys.color.surface-containercolorSurfaceContainer卡片、弹层背景
md.sys.color.outlinecolorOutline输入框边框、分割线
md.sys.shape.corner.mediumshapeAppearanceMediumComponent中等圆角组件
md.sys.typescale.body-mediumtextAppearanceBodyMedium正文文本样式

需要提醒的是,不同版本的 Material Components 库对 surface container 系列属性的支持程度不一样。早期版本只有colorSurface和colorSurfaceVariant,container 系列是后来才补齐的。所以你在写主题的时候,先确认依赖版本,别对着新文档写了一堆属性结果编译不过。

1.3 依赖版本与最低 SDK 的现实约束

M3 的完整能力依赖 Material Components 库的较新版本。我一般用 1.9 以上的稳定版,动态取色(Dynamic Color)需要 Android 12(API 31)及以上才原生支持。这就带来一个现实问题:如果你的minSdk是 21 或 23,动态取色必须做降级,否则低版本设备上要么取不到色,要么直接异常。

我的做法是把动态取色封装成一个"能力探测 + 兜底"的入口,而不是在主题里硬写。具体代码后面第 3 节会展开。这里先记住一个原则:M3 的所有新特性都要假设它在低版本上不可用,先想好降级方案再接入。这个思路贯穿整个迁移过程。

2. 主题体系怎么搭:颜色、字体、形状三件套

M3 的主题不是改一个colorPrimary就完事,它由颜色(color)、字体(type)、形状(shape)三套子系统组成,每套都有自己的主题属性和覆盖方式。这一节讲怎么把这三套搭起来,并且保证明暗模式、动态取色都能正确工作。

2.1 用主题覆盖而不是硬编码颜色

最忌讳的写法是在布局里直接写android:textColor="#6750A4"这种硬编码。一旦要支持深色模式或者换主题,这些地方全得改。正确做法是所有颜色都走主题属性引用:

<!-- 布局里引用主题属性,而不是写死颜色值 --> <com.google.android.material.button.MaterialButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="确认" app:backgroundTint="?attr/colorPrimary" android:textColor="?attr/colorOnPrimary" />

然后在themes.xml里定义这些属性的值。M3 提供了一套基础主题Theme.Material3.DayNight,它已经预置了完整的属性体系,你只需要覆盖想改的部分:

<style name="Theme.MyApp" parent="Theme.Material3.DayNight"> <item name="colorPrimary">@color/md_theme_primary</item> <item name="colorOnPrimary">@color/md_theme_onPrimary</item> <item name="colorPrimaryContainer">@color/md_theme_primaryContainer</item> <item name="colorOnPrimaryContainer">@color/md_theme_onPrimaryContainer</item> <!-- 其余角色色同理 --> </style>

这里有个实操细节:Theme.Material3.DayNight会自动处理明暗切换,但前提是你的颜色资源本身分了values和values-night两套。我建议直接用 Material Theme Builder 这类工具生成一整套色板,它会同时产出明暗两套颜色资源和对应的主题文件,省去手工配对的麻烦,也避免对比度算错。

2.2 字体系统:Type Scale 的落地方式

M3 的字体系统叫 Type Scale,把文本按用途分成 display、headline、title、body、label 五组,每组三档(large/medium/small),一共 15 种样式。落地方式有两种:一种是用TextAppearance主题属性,另一种是直接用MaterialTextView配合textAppearance属性。

我倾向用主题属性统一管理,这样全局改字体只需要动一处:

<style name="Theme.MyApp" parent="Theme.Material3.DayNight"> <item name="textAppearanceBodyMedium">@style/TextAppearance.MyApp.BodyMedium</item> <item name="textAppearanceTitleLarge">@style/TextAppearance.MyApp.TitleLarge</item> </style> <style name="TextAppearance.MyApp.BodyMedium" parent="TextAppearance.Material3.BodyMedium"> <item name="fontFamily">@font/my_custom_font</item> <item name="android:lineHeight">20sp</item> </style>

注意lineHeight这个属性在低版本上有兼容问题,android:lineHeight需要 API 28 以上,低版本要用lineSpacingExtra或lineSpacingMultiplier替代。我在一个minSdk 23的项目里就遇到过行高在旧设备上不生效的情况,最后是给TextAppearance同时配了lineHeight和lineSpacingExtra两套,让系统按版本自己选。

2.3 形状系统:圆角不是随便写的

M3 的形状系统定义了 none、extra-small、small、medium、large、extra-large 几档圆角,对应到ShapeAppearance主题属性。组件默认会引用这些属性,比如MaterialCardView默认用shapeAppearanceMediumComponent。

如果你想让全站卡片圆角统一,不要去每个卡片上写app:cardCornerRadius,而是覆盖形状属性:

<style name="ShapeAppearance.MyApp.Medium" parent="ShapeAppearance.Material3.Corner.Medium"> <item name="cornerFamily">rounded</item> <item name="cornerSize">12dp</item> </style> <style name="Theme.MyApp" parent="Theme.Material3.DayNight"> <item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.MyApp.Medium</item> </style>

这里踩过一个坑:MaterialCardView的圆角优先级是app:cardCornerRadius>shapeAppearance主题属性 > 默认值。如果你在某个卡片上单独写了cardCornerRadius,它会覆盖主题设置。迁移时我建议全局搜一遍cardCornerRadius,把能删的都删掉,统一交给主题管理,否则会出现"大部分卡片圆角对了,个别卡片还是老样子"的诡异现象。

3. 动态取色:让界面跟着壁纸走,但别让它把界面搞崩

动态取色是 M3 最有辨识度的能力,系统从用户壁纸里提取一组色调,应用可以基于这组色调生成整套配色。听起来很美好,但落地时有几个硬约束必须处理好,否则就是给自己埋雷。

3.1 动态取色的能力边界

动态取色依赖 Android 12(API 31)引入的系统能力,通过DynamicColors工具类接入。核心调用就一行:

// 在 Application 或 Activity 的 onCreate 里,setContentView 之前调用 DynamicColors.applyToActivitiesIfAvailable(this)

但这行代码背后有几个前提:设备系统版本达标、系统支持动态取色、应用主题是 M3 主题。三者缺一,这行调用要么无效要么出问题。所以我的做法是包一层判断:

object ThemeCapability { fun isDynamicColorAvailable(context: Context): Boolean { // 版本判断 + 系统能力判断 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && DynamicColors.isDynamicColorAvailable() } fun applyDynamicColorIfAvailable(app: Application) { if (isDynamicColorAvailable(app)) { DynamicColors.applyToActivitiesIfAvailable(app) } } }

DynamicColors.isDynamicColorAvailable()这个方法本身会做系统能力检查,比单纯判断版本号更稳妥。有些定制系统虽然版本号够,但动态取色能力被裁掉了,只判断版本号会误判。

3.2 降级方案:没有动态取色时用什么

低版本设备上,动态取色不可用,应用必须回退到一套预设色板。这套色板就是你在themes.xml里定义的那套静态颜色。所以关键在于:静态色板要设计得足够完整,能独立撑起整个界面,而不是把它当成动态取色的"备胎"随便配。

我的做法是准备两套静态色板:一套品牌色(用于不支持动态取色的设备),一套中性色(用于用户关闭动态取色的情况)。判断逻辑大致是:

fun resolveThemeMode(context: Context): ThemeMode { return when { !ThemeCapability.isDynamicColorAvailable(context) -> ThemeMode.BRAND_STATIC !userPrefersDynamicColor(context) -> ThemeMode.NEUTRAL_STATIC else -> ThemeMode.DYNAMIC } }

用户偏好这个开关建议放在设置页里,给用户一个"跟随壁纸 / 使用品牌配色"的选择。这不是必须的,但实测下来用户对这个开关的接受度挺高,尤其是品牌感强的应用。

3.3 动态取色下的对比度陷阱

动态取色的色调来自壁纸,你无法预知用户壁纸是什么颜色,所以对比度必须由系统生成的On系列颜色来保证。这里最容易犯的错是:在动态取色场景下仍然用自定义颜色做前景。

比如你有个自定义的标签组件,背景用了colorPrimaryContainer,文字却写死了品牌深色。在静态色板下没问题,但动态取色后colorPrimaryContainer可能变成浅绿、浅粉,你写死的深色文字对比度就不够了。正确做法是文字用colorOnPrimaryContainer,让系统保证配对。

我在一个项目里做过对比度抽检,把主要页面的文字/背景组合跑了一遍对比度计算,发现有三处硬编码颜色在动态取色下不达标,全部改成On系列属性后解决。这个检查建议在迁移收尾时做一遍,成本不高但能避免上线后被用户投诉"字看不清"。

4. 组件定制:默认样式不够用时怎么改

M3 组件的默认样式和设计稿往往有差距,尤其是国内设计稿经常在默认基础上做微调。这一节讲几个高频组件的定制方式,以及定制时怎么不破坏 M3 的语义体系。

4.1 按钮:五种类型怎么选

M3 的按钮分 elevated、filled、filled tonal、outlined、text 五种,对应不同的视觉权重。选型逻辑不是看设计稿长什么样,而是看这个操作在页面里的重要性层级:

按钮类型视觉权重适用场景
Filled最高页面主操作,一屏通常只有一个
Filled Tonal较高次要但需要强调的操作
Elevated中需要从背景中"浮起"的操作
Outlined较低中等重要性的操作
Text最低低优先级操作、对话框取消按钮

定制按钮时,优先改主题属性而不是逐个改组件属性。比如统一按钮圆角,改shapeAppearanceSmallComponent;统一按钮高度,改?attr/materialButtonStyle指向的样式。逐个改的问题是维护成本高,而且容易漏。

4.2 顶部应用栏:从 Toolbar 到 TopAppBar 的迁移

M3 的TopAppBar和传统Toolbar用法差别不小。TopAppBar有 center-aligned、medium、large 几种变体,滚动行为也内置了。迁移时最常见的坑是:原来Toolbar上挂的菜单、返回按钮、标题样式,在TopAppBar上要重新配。

<com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/topAppBar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:title="页面标题" app:navigationIcon="@drawable/ic_back" app:menu="@menu/top_app_bar_menu" /> </com.google.android.material.appbar.AppBarLayout>

MaterialToolbar是兼容性比较好的选择,它既支持 M3 的样式属性,又保留了Toolbar的菜单机制。如果直接用TopAppBar的某些变体,菜单的挂载方式会不一样,迁移成本更高。我的建议是:除非设计明确要求 medium/large 那种大标题滚动效果,否则优先用MaterialToolbar,迁移平滑。

4.3 底部导航与 NavigationBarView

M3 的底部导航叫NavigationBar,和 M2 的BottomNavigationView是两套东西。迁移时注意几个差异:NavigationBar的选中指示器是胶囊形背景,图标和文字的颜色走itemActiveIndicatorStyle和itemIconTint、itemTextColor这几组属性。

<com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottomNav" android:layout_width="match_parent" android:layout_height="wrap_content" app:menu="@menu/bottom_nav_menu" app:itemActiveIndicatorStyle="@style/Widget.MyApp.NavigationBar.ActiveIndicator" app:itemIconTint="?attr/colorOnSurfaceVariant" app:itemTextColor="?attr/colorOnSurfaceVariant" />

这里有个细节:选中态的颜色不是通过itemIconTint单独控制的,而是由itemActiveIndicatorStyle里的android:color和图标 tint 的 selector 共同决定。我第一次配的时候只改了itemIconTint,结果选中态图标颜色没变,排查了半天才发现要配 selector。

5. 迁移排错:那些文档里不会写的坑

迁移过程中遇到的问题,大部分不是"不会用",而是"用了但没生效"或者"生效了但和预期不一样"。这一节把我在实际项目里遇到的典型问题整理出来,附上排查链路,方便你对照复现。

5.1 主题属性不生效的排查顺序

"我明明在主题里配了colorPrimary,为什么按钮还是紫色?"这是迁移期最高频的问题。排查按这个顺序走:

  1. 确认 Activity 用的主题对不对。在AndroidManifest.xml里检查android:theme,再看Application级别有没有覆盖。有时候 Application 主题和 Activity 主题不一致,组件取的是 Application 那套。
  2. 确认属性名拼写和版本支持。colorSurfaceContainer这类新属性在老版本库里不存在,写了也不报错,但就是不生效。查一下依赖版本对应的属性列表。
  3. 确认组件有没有被单独覆盖。比如按钮上写了app:backgroundTint,它会覆盖主题的colorPrimary。
  4. 确认有没有values-night干扰。深色模式下如果values-night里没配对应属性,会回退到默认值。

我遇到过一次排查了两小时的问题:主题配得好好的,但某个 Fragment 里的按钮颜色不对。最后发现是那个 Fragment 的布局被 inflate 时用了ContextThemeWrapper包了一层旧主题,组件取的是旧主题的属性。这种问题只能靠逐层检查 Context 解决。

5.2 深色模式下的颜色错乱

深色模式的问题通常出在两处:一是颜色资源没分values-night,二是图片/图标资源没做适配。前者好解决,后者容易被忽略。

图标适配的常见做法是用?attr/colorControlNormal这类主题属性给ImageView上 tint,而不是用带颜色的 PNG。如果设计给了彩色图标,那就要准备明暗两套,或者用VectorDrawable配合主题属性。

还有一个隐蔽的坑:MaterialCardView的cardBackgroundColor默认走colorSurfaceContainer,但如果你在布局里写了app:cardBackgroundColor="@color/white",深色模式下卡片就是白的。迁移时全局搜cardBackgroundColor和backgroundTint,把硬编码颜色都换成主题属性。

5.3 组件默认内边距和设计稿对不上

M3 组件的默认内边距(padding)是按 Material 规范定的,和国内设计稿经常有出入。比如MaterialButton默认有最小高度和水平内边距,设计稿可能要求更紧凑。

处理方式有两种:一是用android:insetTop、android:insetBottom调整按钮的"视觉外扩",二是用minHeight、paddingStart/End直接改。我倾向用 inset 系列,因为它不改变按钮的实际触摸区域,只是调整视觉尺寸,对可访问性更友好。

<com.google.android.material.button.MaterialButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:insetTop="0dp" android:insetBottom="0dp" app:minHeight="36dp" android:paddingStart="16dp" android:paddingEnd="16dp" />

注意:改 inset 之后按钮的点击热区会变小,如果设计要求按钮视觉上紧凑但热区不变,应该用TouchDelegate扩大热区,而不是一味缩小 inset。

5.4 动态取色与自定义颜色的冲突

前面提过动态取色下不能用硬编码前景色,这里补充一个更隐蔽的情况:自定义 drawable 里的颜色。比如你用 shape drawable 画了个背景,里面写死了#FFFFFF,动态取色后这个背景不会跟着变,和周围的主题色格格不入。

解决办法是把 drawable 里的颜色改成主题属性引用:

<!-- res/drawable/bg_tag.xml --> <shape xmlns:android="http://schemas.android.com/apk/res/android"> <solid android:color="?attr/colorSecondaryContainer" /> <corners android:radius="?attr/shapeAppearanceSmallComponent" /> </shape>

注意?attr在 drawable 里引用主题属性需要 API 21 以上,低版本要用?android:attr或者代码里动态设置。这个兼容性细节在迁移老项目时经常被忽略。

6. 落地节奏与验收:怎么判断迁移做完了

M3 迁移不是一次性把所有页面改完,那样风险太大。我的做法是分阶段推进,每个阶段有明确的验收标准,避免"改了一半不知道进度"。

6.1 分阶段迁移的推进顺序

第一阶段只做基础设施:依赖升级、主题体系搭建、颜色/字体/形状三套属性配好,但页面布局不动。这个阶段验收标准是"应用能正常启动,原有页面视觉不变"。

第二阶段做组件替换:把Button换成MaterialButton、Toolbar换成MaterialToolbar、BottomNavigationView换成 M3 版本。这个阶段验收标准是"组件功能正常,交互无回归"。

第三阶段做视觉对齐:按设计稿调整圆角、间距、颜色,接入动态取色。这个阶段验收标准是"明暗模式、动态取色、静态色板三种场景下视觉都正确"。

第四阶段做收尾检查:全局搜硬编码颜色、检查对比度、跑一遍低版本设备。这个阶段验收标准是"无硬编码颜色残留,对比度抽检通过,低版本无崩溃"。

分阶段的好处是每个阶段都能独立回滚,出问题定位范围小。我第一个项目图快,想一次性改完,结果一个主题属性配错导致整个应用颜色错乱,回滚都找不到从哪回。

6.2 验收清单:迁移完成的判断标准

收尾阶段我一般跑一遍这个清单,全部通过才算迁移完成:

  • 全局搜索#[0-9a-fA-F]{6},确认布局和 drawable 里没有硬编码颜色(少数确实需要固定的除外,比如品牌 logo)。
  • 明暗模式下逐页走查,重点看文字对比度、图标可见性、卡片背景。
  • 动态取色开启和关闭两种状态下各走查一遍。
  • 低版本设备(API 31 以下)上确认无崩溃、无颜色异常。
  • 检查所有cardCornerRadius、backgroundTint这类会覆盖主题的属性,确认没有遗漏的硬编码。
  • 用对比度工具抽检主要文字/背景组合,确保达到 WCAG AA 标准(正文 4.5:1,大字号 3:1)。

这套清单跑下来大概半天到一天,但能挡掉大部分上线后才会暴露的问题。我在第二个项目里严格执行了这套流程,上线后关于"颜色不对""字看不清"的反馈基本为零,而第一个项目没做这套检查,上线一周内就收到了好几条相关反馈。

6.3 一个容易被忽略的收尾项:动画与过渡

M3 对动效也有规范,比如页面过渡、组件状态变化的时长和缓动曲线。这部分在迁移时最容易被跳过,因为不影响功能。但如果设计稿明确要求了动效,建议在收尾阶段一并处理,用MotionSpec或者直接配android:interpolator和android:duration。

我的经验是动效这块不要追求一步到位,先把颜色、字体、形状、组件这些"静态"部分做扎实,动效作为增量优化。因为动效出问题不容易被发现,但改起来又牵一发动全身,放在最后处理风险最低。

7. 我个人在 M3 迁移里最想强调的几点

做了两轮完整迁移,最大的体会是:M3 的难点不在"会不会用某个组件",而在"有没有建立起角色化的思维"。颜色不是拿来就用,而是先问"这个颜色在语义上是什么角色";组件不是照着设计稿堆,而是先问"这个操作在页面里的权重是多少"。想清楚这两件事,代码写起来反而快。

另外一个实操建议:迁移期间一定要保留一套完整的静态色板作为兜底,不要过度依赖动态取色。动态取色是加分项,不是基础项,用户关掉它或者设备不支持时,界面必须依然完整好看。我见过一些项目把动态取色当默认,结果在不支持的设备上界面灰扑扑的,体验反而下降。

最后分享一个小技巧:迁移时给主题属性加注释,标明每个属性对应的设计令牌和用途。比如<!-- colorPrimaryContainer: 卡片选中背景,对应 md.sys.color.primary-container -->。项目大了之后,这些注释能帮后来的人(包括几个月后的自己)快速理解为什么这么配,比翻设计文档快得多。