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当文字结果在深色模式下文字几乎看不见。正确做法是用colorPrimaryContainercolorOnPrimaryContainer这对组合它们在明暗两套主题下都保证对比度。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和colorSurfaceVariantcontainer 系列是后来才补齐的。所以你在写主题的时候先确认依赖版本别对着新文档写了一堆属性结果编译不过。1.3 依赖版本与最低 SDK 的现实约束M3 的完整能力依赖 Material Components 库的较新版本。我一般用 1.9 以上的稳定版动态取色Dynamic Color需要 Android 12API 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_widthwrap_content android:layout_heightwrap_content android:text确认 app:backgroundTint?attr/colorPrimary android:textColor?attr/colorOnPrimary /然后在themes.xml里定义这些属性的值。M3 提供了一套基础主题Theme.Material3.DayNight它已经预置了完整的属性体系你只需要覆盖想改的部分style nameTheme.MyApp parentTheme.Material3.DayNight item namecolorPrimarycolor/md_theme_primary/item item namecolorOnPrimarycolor/md_theme_onPrimary/item item namecolorPrimaryContainercolor/md_theme_primaryContainer/item item namecolorOnPrimaryContainercolor/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 nameTheme.MyApp parentTheme.Material3.DayNight item nametextAppearanceBodyMediumstyle/TextAppearance.MyApp.BodyMedium/item item nametextAppearanceTitleLargestyle/TextAppearance.MyApp.TitleLarge/item /style style nameTextAppearance.MyApp.BodyMedium parentTextAppearance.Material3.BodyMedium item namefontFamilyfont/my_custom_font/item item nameandroid:lineHeight20sp/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 nameShapeAppearance.MyApp.Medium parentShapeAppearance.Material3.Corner.Medium item namecornerFamilyrounded/item item namecornerSize12dp/item /style style nameTheme.MyApp parentTheme.Material3.DayNight item nameshapeAppearanceMediumComponentstyle/ShapeAppearance.MyApp.Medium/item /style这里踩过一个坑MaterialCardView的圆角优先级是app:cardCornerRadiusshapeAppearance主题属性 默认值。如果你在某个卡片上单独写了cardCornerRadius它会覆盖主题设置。迁移时我建议全局搜一遍cardCornerRadius把能删的都删掉统一交给主题管理否则会出现大部分卡片圆角对了个别卡片还是老样子的诡异现象。3. 动态取色让界面跟着壁纸走但别让它把界面搞崩动态取色是 M3 最有辨识度的能力系统从用户壁纸里提取一组色调应用可以基于这组色调生成整套配色。听起来很美好但落地时有几个硬约束必须处理好否则就是给自己埋雷。3.1 动态取色的能力边界动态取色依赖 Android 12API 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_widthmatch_parent android:layout_heightwrap_content com.google.android.material.appbar.MaterialToolbar android:idid/topAppBar android:layout_widthmatch_parent android:layout_height?attr/actionBarSize app:title页面标题 app:navigationIcondrawable/ic_back app:menumenu/top_app_bar_menu / /com.google.android.material.appbar.AppBarLayoutMaterialToolbar是兼容性比较好的选择它既支持 M3 的样式属性又保留了Toolbar的菜单机制。如果直接用TopAppBar的某些变体菜单的挂载方式会不一样迁移成本更高。我的建议是除非设计明确要求 medium/large 那种大标题滚动效果否则优先用MaterialToolbar迁移平滑。4.3 底部导航与 NavigationBarViewM3 的底部导航叫NavigationBar和 M2 的BottomNavigationView是两套东西。迁移时注意几个差异NavigationBar的选中指示器是胶囊形背景图标和文字的颜色走itemActiveIndicatorStyle和itemIconTint、itemTextColor这几组属性。com.google.android.material.bottomnavigation.BottomNavigationView android:idid/bottomNav android:layout_widthmatch_parent android:layout_heightwrap_content app:menumenu/bottom_nav_menu app:itemActiveIndicatorStylestyle/Widget.MyApp.NavigationBar.ActiveIndicator app:itemIconTint?attr/colorOnSurfaceVariant app:itemTextColor?attr/colorOnSurfaceVariant /这里有个细节选中态的颜色不是通过itemIconTint单独控制的而是由itemActiveIndicatorStyle里的android:color和图标 tint 的 selector 共同决定。我第一次配的时候只改了itemIconTint结果选中态图标颜色没变排查了半天才发现要配 selector。5. 迁移排错那些文档里不会写的坑迁移过程中遇到的问题大部分不是不会用而是用了但没生效或者生效了但和预期不一样。这一节把我在实际项目里遇到的典型问题整理出来附上排查链路方便你对照复现。5.1 主题属性不生效的排查顺序我明明在主题里配了colorPrimary为什么按钮还是紫色这是迁移期最高频的问题。排查按这个顺序走确认 Activity 用的主题对不对。在AndroidManifest.xml里检查android:theme再看Application级别有没有覆盖。有时候 Application 主题和 Activity 主题不一致组件取的是 Application 那套。确认属性名拼写和版本支持。colorSurfaceContainer这类新属性在老版本库里不存在写了也不报错但就是不生效。查一下依赖版本对应的属性列表。确认组件有没有被单独覆盖。比如按钮上写了app:backgroundTint它会覆盖主题的colorPrimary。确认有没有values-night干扰。深色模式下如果values-night里没配对应属性会回退到默认值。我遇到过一次排查了两小时的问题主题配得好好的但某个 Fragment 里的按钮颜色不对。最后发现是那个 Fragment 的布局被 inflate 时用了ContextThemeWrapper包了一层旧主题组件取的是旧主题的属性。这种问题只能靠逐层检查 Context 解决。5.2 深色模式下的颜色错乱深色模式的问题通常出在两处一是颜色资源没分values-night二是图片/图标资源没做适配。前者好解决后者容易被忽略。图标适配的常见做法是用?attr/colorControlNormal这类主题属性给ImageView上 tint而不是用带颜色的 PNG。如果设计给了彩色图标那就要准备明暗两套或者用VectorDrawable配合主题属性。还有一个隐蔽的坑MaterialCardView的cardBackgroundColor默认走colorSurfaceContainer但如果你在布局里写了app:cardBackgroundColorcolor/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_widthwrap_content android:layout_heightwrap_content android:insetTop0dp android:insetBottom0dp app:minHeight36dp android:paddingStart16dp android:paddingEnd16dp /注意改 inset 之后按钮的点击热区会变小如果设计要求按钮视觉上紧凑但热区不变应该用TouchDelegate扩大热区而不是一味缩小 inset。5.4 动态取色与自定义颜色的冲突前面提过动态取色下不能用硬编码前景色这里补充一个更隐蔽的情况自定义 drawable 里的颜色。比如你用 shape drawable 画了个背景里面写死了#FFFFFF动态取色后这个背景不会跟着变和周围的主题色格格不入。解决办法是把 drawable 里的颜色改成主题属性引用!-- res/drawable/bg_tag.xml -- shape xmlns:androidhttp://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 --。项目大了之后这些注释能帮后来的人包括几个月后的自己快速理解为什么这么配比翻设计文档快得多。
阅读完成 · 觉得有帮助?