Material You 动态取色,实际项目中落地难点
Material You 动态取色,实际项目中落地难点
去年把主工程 Material Components 从 1.5.0 升到 1.9.0 时,我们顺手打开了 DynamicColors。测试包发出去第二天,设计组就在企业微信里扔了一张截图:某台 Pixel 6 上,开启动态取色后,主站的 Toolbar 和底部导航栏变成了一种极其艳丽的玫红色。那台测试机的壁纸恰好是一组晚霞照片。设计师问,能不能让颜色不要那么「跳」。我截了一张系统设置界面的图回过去——人家系统设置也用的同一张壁纸,但设置里的主色是沉稳的暗橙红,而我们的应用里直接蹦出了荧光感。问题从这一刻开始,远比「调一下饱和度」复杂得多。
DynamicColors.applyToActivitiesIfAvailable 的调用时机陷阱
Material Components 官方文档里的标准写法是在 Application 里调一行:
DynamicColors.applyToActivitiesIfAvailable(this)看起来足够无侵入。我试了以后发现,这行代码放在 Application.onCreate 里,对于已经使用了 SplashScreen API(androidx.core:core-splashscreen)的项目会出一个很隐蔽的问题。Splash 主题本身是一个独立的 Window 背景,Activity 的 theme 在 super.onCreate 之前就已经被系统用来渲染启动窗口。如果你的 Application 先 apply 了 dynamic color overlay,Splash Screen 会正确取到色;但如果你在 BaseActivity 里又对 theme 做了某些动态修正(比如根据用户手动设置的皮肤包切换 themeResId),super.onCreate 之后调用 setTheme 会把之前叠加的 dynamic overlay 冲掉一部分。
更坑的是 1.7.0 之前的 Material Components 库,DynamicColors.applyToActivitiesIfAvailable 内部注册了一个 ActivityLifecycleCallbacks,在 onActivityPreCreated 阶段通过 context.theme.applyStyle(R.style.ThemeOverlay_DynamicColors, true) 打 overlay。这个 true 意味着 force,会覆盖已有属性。可如果你的 Activity 在 onCreate 里通过 LayoutInflater.setFactory2 或者 AppCompatDelegate.setDefaultNightMode 做了 theme 操作,优先级顺序就会变得不可预期。我们曾经遇到过一个线上反馈:用户手动切换了深色模式,返回应用后 BottomNavigationView 的选中色回到了默认靛蓝,而不是动态提取色。复现路径极其苛刻,必须是「系统动态取色开启 + 应用内手动切夜模式 + 特定厂商 ROM」。后来定位到是 AppCompatDelegateImpl 在 night mode 变化时重建了 context,重建后的 ContextThemeWrapper 没有重新挂载 Material 库的那个 overlay。
解决方式倒也直接,放弃 Application 级别的全局自动 apply,改为在 BaseActivity 的 attachBaseContext 或者 onApplyThemeResource 阶段显式注入:
override fun attachBaseContext(newBase: Context) {
val dynamicContext = DynamicColors.wrapContextIfAvailable(newBase)
super.attachBaseContext(dynamicContext)
}但 wrapContextIfAvailable 在 1.6.0 和 1.8.0 的行为还不一样。1.6.0 版本它不会自动处理 Configuration change,旋转屏幕后新 Context 丢失 overlay。1.8.0 修了这个 bug,却又引入了另一个问题:在某些 Android 12(API 31)的设备上,wrapContextIfAvailable 内部检查 isDynamicColorAvailable 时,调用了 WallpaperManager.getWallpaperColors,而调用方如果是 attachBaseContext,此时 Activity 尚未完全 attach 到 WindowManager,部分国产 ROM 会抛出一个非法状态异常,虽然被库内部 catch 住返回 false,但 StrictMode 会报一条 diskRead 违规——因为 WallpaperManager 查壁纸颜色走的 Binder,严格模式下算一次隐式 IO。
Monet 提取的颜色并不「保险」
Material You 的核心是 Monet 引擎,应用层不需要直接碰它,系统通过 WallpaperColors 类把提取结果喂给 framework。但应用层真要想做精细化控制,绕不开 WallpaperColors.fromBitmap 或者直接读 WallpaperManager.getWallpaperColors。
我们在调研阶段写了一个测试页面,把系统返回的 primaryColor、secondaryColor、tertiaryColor 以及对应的 tonal palette 全部打出来看。结论很打击人:同一张壁纸,Android 12 和 Android 13 提取出的 seed color 可能完全不同。Android 12 更倾向于取壁纸里的主视觉色,而 Android 13 的 Monet 明显加强了「中和」与「可用性」校正,会让 seed color 向中性色偏移。这对设计师来说是个灾难,他们在 Figma 里基于 Pixel 7 Android 13 调了一套看起来很舒服的互补色方案,放到 Android 12 的 OPPO Find X3 上,颜色直接跑偏。
更实际的问题是饱和度失控。Google 的官方设计文档说 tonal palette 会保证在 99% 的壁纸上产生符合 WCAG 对比度的色值,但它没保证「符合你们公司品牌调性」。我们抓取了内部测试的 200 多张常见壁纸,发现 Monet 生成的 colorPrimary 在 HCT 色彩空间里,chroma 值(大致对应饱和度)最高能冲到 60 以上。对于金融类 App 这种需要稳重感的界面,这种高饱和色出现在顶部导航栏或主要按钮上,视觉风险极高。
Material Components 库内部其实留了一个口子:你可以继承 DynamicColors.OnAppliedCallback,在颜色 overlay 被 apply 之后做二次修正。但这个回调发生在 Activity 启动之后,你要是想改 theme 属性,只能再 apply 一个自定义的 ThemeOverlay。这就造成了双重 overlay,属性解析链变长,且对非 Material 组件(比如自家沉淀了五年的自定义 View)无效——它们读的是直接硬编码的 colorRes,不跟着 theme attribute 走。
深色模式下的双重叠加噩梦
动态取色在浅色模式下还勉强能看,到了深色模式,问题会乘数级放大。Material3 的 dark scheme 生成逻辑是把 seed color 映射到一个固定算法里产生 surface、surfaceVariant、onSurface 等系列颜色。这个算法假设你的 seed color 本身在浅色模式下是正常主色。可如果用户壁纸是一张深色系图片,seed color 本身就很暗,生成出的 dark scheme 会让 surface 和 background 几乎融为一体。
我们在 Material Components 1.8.0 上遇到一个具体 bug:当 seed color 的 luminance 低于某个阈值时,动态生成的 darkColorScheme 里 surfaceTint 的颜色会异常地亮,导致所有带 elevation 的 CardView 出现一层诡异的「辉光」。去翻 GitHub issue,发现 #3527 有人报了同样的问题,维护者回复说已经在 1.9.0-alpha 里修复。升到 1.9.0 之后,那层辉光确实没了,但新的问题来了——深色模式下 colorPrimaryContainer 和 colorSurface 的对比度在某些壁纸上跌到了 2.8:1 以下,低于 WCAG AA 标准。对于需要过无障碍审核的应用,这是硬性 block。
还有系统栏的问题。Android 12 引入的 edge-to-edge 特性配合动态取色,状态栏 icon 颜色是由 WindowInsetsController 控制的,但状态栏背景色(或透明时的 scrim)却跟着 dynamic color 走。如果你的 wallpaper 提取色是浅黄绿色,系统状态栏文字会自动反色成黑,但你的应用内部如果有一个 CollapsingToolbarLayout,它折叠后的 statusBarScrim 也是浅黄绿,此时状态栏文字就看不清了。Material Components 的 AppBarLayout 在 1.7.0 之前没有暴露一个 attribute 让你单独控制 statusBarScrim 是否跟随 dynamic color,你只能手动在代码里 setStatusBarScrimColor,或者在自己的 theme 里硬覆盖:
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="appBarLayoutStyle">@style/Widget.App.AppBarLayout</item>但这又破坏了动态取色的初衷。
低端机与 ANR 的隐形成本
很多人以为动态取色就是系统帮你算好了颜色,应用层只管读。实际上,如果你的应用需要在某些场景下自己算 palette(比如你想预生成几张配色卡片展示给用户看),你不可避免地要碰 WallpaperManager.getWallpaperColors()。
这个 API 在 Pixel 上很快,Binder 过去直接拿系统服务缓存的结果。但在一些国内厂商的 Android 12 设备上,这个调用第一次执行时可能会触发一次完整的壁纸颜色分析——而那张壁纸可能是用户刚从相册选的 4K 原图。我们在主线程不小心调了一次,vivo 某机型上直接 ANR,trace 里卡在 WallpaperManager.getWallpaperColors 长达 2.3 秒。StrictMode 的 diskRead 和 slowCall 同时报警。
val colors = wallpaperManager.getWallpaperColors(WallpaperManager.FLAG_SYSTEM)即使是只读系统已经算好的颜色,也不建议在