最近在折腾一个App的日间模式与夜间模式切换需求绕了一大圈后发现绝大多数问题都集中在“颜色值重载”这一件事上。Android里讲日间/夜间模式真不是把背景调成深色那么简单而是要让同一套界面在不同的模式下文字、背景、分割线、选中态、进度条、状态栏全都按规则替换。弄清楚“颜色值重载”的机制整个夜间模式方案就完成了一大半。这一篇我打算把实际做日间/夜间切换的完整方案写出来包括资源目录怎么建、自定义属性怎么设计、BaseActivity怎么写、踩过的坑怎么避让同样在做类似需求的朋友少走弯路。不管你是刚接触Android开发的初学者还是接手老旧项目需要补夜间模式的熟手这套思路基本可以直接落地。文章会尽量用大白话讲遇到关键原理我也会展开说明这样既能直接照着抄也能知道每一步背后的原因。1. 日间/夜间模式适配的整体设计1.1 为什么不能只做一套“黑色皮肤”先说我第一次给老项目补夜间模式时的惨状设计同学给了一套纯黑底配色的效果图我直接在布局里把背景换成了深色结果页面一打开文字和背景糊成一片深色文字完全看不清分割线直接消失进度条白花花地在深色背景上闪烁弹窗一半亮一半暗。原因很简单界面上每一个控件使用的颜色都是写死的只改背景相当于只换了一个壳壳内部的零件全都还是原来那套。真实的夜间模式是一个体系化替换过程。一个页面上的颜色数量往往远超你预期页面底色、卡片底色、主文字、辅助文字、占位文字、分割线、选中态、按压态、图标默认色、图标选中色、进度条轨道、进度条进度、输入框光标、Toast底色、Dialog遮挡层、状态栏和导航栏颜色等等。这些颜色都需要在模式切换时同步变化而且不能出现一处遗漏否则就是“一颗老鼠屎坏了一锅粥”的观感。所以我在做需求前先做了一个动作把所有页面里出现的颜色全部抽出来去重之后画了一张颜色映射表日间一套值夜间一套值。有了这张表后续的所有实现都围绕它展开思路立刻清晰了。1.2 资源覆盖机制与“颜色值重载”的真实含义标题里说的“颜色值重载”这里的重载并不是面向对象里的方法重载而是指Android的资源目录覆盖机制。Android系统允许我们在res目录下建多个带限定符的子目录比如values-night、drawable-night、layout-night等同一份资源在不同限定符下可以有不同取值系统会按照当前设备的配置自动选择最合适的那一份。也就是说同样一个R.color.bg_page在白天可能会解析成#F5F6F7在夜间会自动解析成#121212。这就是最基础、也是最关键的颜色重载能力。app/src/main/res/ ├── values/ │ ├── attrs.xml │ ├── colors.xml │ └── themes.xml ├── values-night/ │ ├── colors.xml │ └── themes.xml └── drawable-night/当系统处于深色模式时Android的资源解析器会自动优先匹配带night限定符的目录处于浅色模式时则匹配普通目录。开发者不需要在代码里写任何判断逻辑只要保证两个目录下资源名字完全相同系统就会自动完成选择。这也是Google官方推荐的适配方式稳定、省心、性能好。但是仅仅依赖values-night还有一个明显短板如果你只在布局里使用color/xxx那么当你在代码里动态创建View或者动态修改背景色时很容易写死成一个固定颜色这样就绕过了资源重载的机制。所以我们需要引入主题属性把颜色的使用层级再抽象一层。1.3 主题属性从“物理颜色”到“逻辑颜色”我一直建议项目里不要直接用color/color_white、color/color_black这类物理命名因为它们一旦在夜间模式下需要变成别的颜色就会造成混乱。更合理的做法是给颜色起一个“逻辑名”比如bg_page、text_primary、line_divider然后在主题里声明对应的自定义属性。旧写法android:backgroundcolor/color_white新写法android:background?attr/bgPage这里?attr/bgPage表示引用当前主题里名为bgPage的属性值。主题在不同模式下可以给同一个属性绑定不同颜色资源而布局代码完全不用感知当前是日间还是夜间。比如在values/themes.xml里定义主题时bgPage指向color/bg_page而color/bg_page在values和values-night下分别有不同色值这样一层层传递下去就实现了“逻辑颜色名固定物理色值随模式变化”。这样做还有一个额外好处如果以后要做品牌换肤或者用户自定义主题只需要再写几个新主题把attr指向另一套颜色布局和代码几乎不用动。这是一笔非常划算的架构投资。2. 搭建一套可持续使用的颜色体系2.1 先把颜色清单整理出来正式开始写代码之前我强烈建议先把项目涉及的所有颜色梳理成一张表。实践下来这张表既是设计和开发的沟通桥梁也是后续自测的验收依据。下面是我比较常用的一套语义化颜色清单模板大家可以根据自己的设计稿调整。语义名日间色值夜间色值主要用途bg_page#F5F6F7#121212页面底色、窗口背景bg_card#FFFFFF#1E1E1E卡片、弹窗、列表项背景text_primary#1A1A1A#E6E6E6标题、核心正文text_secondary#666666#999999辅助说明文字text_placeholder#B0B0B0#666666输入框占位符line_divider#EDEDED#333333分割线、边框icon_default#666666#CCCCCC普通图标icon_selected#FF5A00#FF7A33选中态图标、品牌色fill_pressed#F0F0F0#2C2C2C按压态背景progress_track#D8D8D8#404040进度条轨道progress_bar#FF5A00#FF7A33进度条指示色这里有一个容易被忽略的细节夜间下的品牌色最好稍微调亮一点因为深色背景下同样饱和度的颜色眼睛看起来会偏暗适当提亮能保证对比度。设计和开发可以约定一套专门针对深色的品牌色而不是简单把日间的品牌色拿过来直接用。2.2 声明自定义属性颜色清单确定后在res/values/attrs.xml里把需要用到的逻辑颜色声明成自定义属性。属性名尽量和语义名保持一致方便记忆和检索。resources attr namebgPage formatcolor|reference / attr namebgCard formatcolor|reference / attr nametextPrimary formatcolor|reference / attr nametextSecondary formatcolor|reference / attr nametextPlaceholder formatcolor|reference / attr namelineDivider formatcolor|reference / attr nameiconDefault formatcolor|reference / attr nameiconSelected formatcolor|reference / attr namefillPressed formatcolor|reference / attr nameprogressTrack formatcolor|reference / attr nameprogressBar formatcolor|reference / /resourcesformat属性里写color|reference表示这个属性既可以放一个直接的色值也可以引用一个颜色资源。我建议一律引用颜色资源不要直接在attr里写死#号色值这样后续如果要统一调整只需要改colors.xml即可。2.3 主题里绑定颜色资源接下来在values/themes.xml中定义App主题把每个attr绑定到语义化的颜色资源上。style nameTheme.Demo parentTheme.AppCompat.DayNight.NoActionBar item namebgPagecolor/bg_page/item item namebgCardcolor/bg_card/item item nametextPrimarycolor/text_primary/item item nametextSecondarycolor/text_secondary/item item nametextPlaceholdercolor/text_placeholder/item item namelineDividercolor/line_divider/item item nameiconDefaultcolor/icon_default/item item nameiconSelectedcolor/icon_selected/item item namefillPressedcolor/fill_pressed/item item nameprogressTrackcolor/progress_track/item item nameprogressBarcolor/progress_bar/item /style而values/colors.xml和values-night/colors.xml分别定义这些颜色资源。比如日间目录里bg_page是#F5F6F7夜间目录里同名资源是#121212。这里有一个关键设计themes.xml只保留一份不需要在values-night下再写一份重复的themes.xml。因为主题item指向的是color/bg_page而color/bg_page这个资源本身在不同目录下有不同的值系统会按照当前模式解析出正确的颜色。这样维护成本最低也不会出现两份主题配置不小心改漏一半的问题。注意如果你在某些设备上遇到主题不生效可以检查是否在AndroidManifest.xml里给Application或Activity设置了正确的主题名称。如果项目里同时存在多套主题还需要确认Manifest中配置的确实是带了DayNight风格的那套。2.4 别忘了状态栏、导航栏和进度条很多人做夜间模式只盯着页面内颜色忽略了系统级UI的颜色结果页面内容完全正常但状态栏依然白底黑字在深色界面下显得极其刺眼。处理状态栏和导航栏需要同时在主题里配置系统UI标志。在values/themes.xml中加item nameandroid:windowLightStatusBartrue/item item nameandroid:windowLightNavigationBartrue/item item nameandroid:statusBarColorcolor/bg_page/item item nameandroid:navigationBarColorcolor/bg_page/item item nameandroid:windowBackgroundcolor/bg_page/itemwindowLightStatusBar为true时状态栏图标是深色的适合浅色背景为false时状态栏图标是浅色的适合深色背景。所以在values-night/themes.xml中要把这两个值设成false同时把状态栏、导航栏、窗口背景都指向夜间模式的对应颜色。进度条也是一个容易忽略的地方。系统默认进度条颜色可能和你的品牌色不一致在深色背景下尤其难看。建议使用自定义属性包裹进度条颜色或者直接在布局里给ProgressBar指定tint。ProgressBar android:layout_widthwrap_content android:layout_heightwrap_content android:indeterminateTint?attr/progressBar /这样进度条颜色也会跟随日间/夜间切换不会再出现一条亮白色进度条在黑色背景上晃眼的情况。3. 实操把颜色值重载接进现有工程3.1 环境准备与目录创建在Android Studio里右键res目录选择New - Directory输入“values-night”系统会自动创建一个限定符目录。同样如果需要夜间版的图片可以创建drawable-night目录。目录创建好后把对应的colors.xml复制到values-night下然后逐个把色值改为夜间值。如果是老项目我建议先别急着把所有页面一次性改完而是挑一个使用频率最高、页面结构最简单的模块做试点。比如先改个人中心页把它的背景、文字、分割线全部切到新体系运行起来看效果再逐步推广到其他页面。这样风险可控也不会碰到改了一半回不去的尴尬局面。3.2 BaseActivity里统一处理主题为了不让每个Activity都写一遍主题判断和状态栏处理我会在BaseActivity中统一处理。关键点在于setTheme的调用时机必须在super.onCreate之前否则Activity已经创建好了再setTheme系统只会重新应用主题而不会重新创建窗口部分效果会不生效。abstract class BaseActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { setTheme(R.style.Theme_Demo) super.onCreate(savedInstanceState) applySystemUiByMode() } override fun onResume() { super.onResume() applySystemUiByMode() } private fun applySystemUiByMode() { val isNight resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK Configuration.UI_MODE_NIGHT_YES // 状态栏与导航栏颜色从主题属性中解析 window.statusBarColor resolveThemeColor(R.attr.bgPage) window.navigationBarColor resolveThemeColor(R.attr.bgPage) // 浅色模式下状态栏图标用深色深色模式下用浅色 WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars !isNight WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars !isNight } private fun resolveThemeColor(AttrRes attrRes: Int): Int { val typedValue TypedValue() theme.resolveAttribute(attrRes, typedValue, true) return typedValue.data } }onResume里再调用一次是为了防止从后台切换模式回来后状态栏颜色没有刷新。这个坑我踩过一次用户切到系统设置里打开深色模式再切回App页面颜色变了但状态栏还是原来的浅色模式图标后来发现就是回调时机的问题。3.3 代码里读取颜色的正确姿势布局文件中可以用?attr/xxx代码里就需要显式解析主题属性或者使用ContextCompat获取颜色资源。老代码里常见的getResources().getColor(R.color.xxx)方法已经废弃而且它不会考虑主题属性直接拿资源表的值。如果你在代码里new了一个View然后给它setBackgroundColor请务必通过主题解析否则颜色不会跟随夜间模式变化。推荐写法// 方式一上下文兼容 val color ContextCompat.getColor(this, R.color.text_primary) // 方式二带主题解析的颜色资源 val color ResourcesCompat.getColor(resources, R.color.text_primary, theme) // 方式三解析自定义属性 fun resolveAttrColor(AttrRes attrRes: Int): Int { val ta theme.obtainStyledAttributes(intArrayOf(attrRes)) val color ta.getColor(0, 0) ta.recycle() return color }任何时候都不要在代码里出现Color.parseColor(#FFFFFF)这种硬编码色值一旦出现它就是夜间模式下怎么都改不掉的一个“漏网之鱼”。3.4 手动切换日间/夜间的入口如果你不想完全跟随系统而是要做一个手动切换开关最常见的方式是使用AppCompatDelegate。private fun switchToNight() { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) // 如果当前Activity没有自动重建可以手动recreate() } private fun switchToDay() { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) } private fun followSystem() { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) }setDefaultNightMode是一个全局配置设置之后已经启动的Activity会收到配置变化通常会自动重建。但是不同厂商ROM上表现不完全一致有些设备不会自动重建所有Activity所以稳妥做法是切换成功后手动把当前任务栈里的Activity重新创建一遍或者通过广播/事件通知BaseActivity统一recreate。我在项目里通常会把当前模式存到SharedPreferencesApp启动时先读取这个值再决定设置哪种模式这样可以做到“上次选了什么下次打开还是什么”。3.5 关于“无重建切换”的一点探索很多产品经理希望点击切换按钮后界面不要出现重建闪烁最好能无缝切换。但官方的DayNight切换本质上依赖资源配置变化系统为了应用新资源天然会销毁并重建Activity。目前并没有一个完美且通用的无重建方案倒是有一些缓解方案。实践中比较有效的是给recreate过程加上过渡动画override fun recreate() { val options ActivityOptionsCompat.makeCustomAnimation( this, R.anim.fade_in_duration_150, R.anim.fade_out_duration_150 ) startActivity(intent, options.toBundle()) finish() }不过这个方案也有副作用它会让所有recreate不再走默认的保存状态流程需要额外维护页面临时数据。如果页面比较复杂我建议还是接受重建用ProgressBar遮罩或者让启动页过渡来弱化顿挫感把体验重点放在切换后的稳定性和状态恢复上。3.6 图片、图标、drawable也要放在重载体系里颜色值重载不仅限于colors.xmldrawable资源同样需要处理。比如一个默认是深色箭头的图标夜间模式下应该变成浅色箭头。最简单的办法是准备两套相同的drawable一个放在drawable一个放在drawable-night引用时都写R.drawable.ic_arrow_right系统会自动选择正确的图标。如果是VectorDrawable尽量让填充色引用颜色资源而不是直接写死色值。比如vector xmlns:androidhttp://schemas.android.com/apk/res/android android:width24dp android:height24dp android:viewportWidth24 android:viewportHeight24 path android:fillColorcolor/icon_default android:pathDataM12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0 / /vector这样icon_default在不同模式下取不同值图标就自动适配了。需要注意矢量图在部分低版本上如果直接引用?attr属性会有兼容性问题而引用color资源则没有这个顾虑所以建议统一用color资源。3.7 第三方SDK中的颜色处理老项目里多少会用到第三方组件有些组件不允许直接修改颜色有些允许通过主题或属性配置。我遇到过的比较典型的情况有分享面板的弹出背景、扫码页的四个角标、IM会话的气泡颜色、图表库的坐标轴文字颜色。针对这类SDK我通常的做法是查看SDK文档里是否支持自定义主题或颜色属性支持的话就在App的主题里差异化配置。不支持的话就在SDK初始化位置读取当前模式的isNight然后手动传入不同颜色。如果源码开放可以直接在源码里把硬编码颜色替换为主题属性。排查时可以在夜间模式下逐个页面截图对比优先处理高频页面边缘页面放到后面整体节奏会更可控。4. 常见问题与排查技巧实录4.1 状态栏和导航栏不跟随怎么办状态栏不跟随是夜间适配里最常遇到的问题之一。除了在主题里配置windowLightStatusBar之外还需要注意Android 10以下的系统没有浅色导航栏图标的系统级支持只有部分厂商ROM支持。如果在某些设备上图标颜色不变可以退回到手动设置if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { val decorView window.decorView decorView.systemUiVisibility if (isNight) 0 else View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR }这个写法在API 30之后已经标记废弃但作为低版本兼容兜底仍然可用。新版推荐使用WindowInsetsControllerCompat也就是BaseActivity示例代码里的写法。两种方式可以同时存在用版本判断分开走。4.2 自定义View画出来的颜色没有变自定义View在夜间模式下不更新多半是这几个原因onDraw里直接用了Color.WHITE、Color.BLACK等常量。onDraw里调用了getResources().getColor(R.color.xxx)但没有传入theme。View开启了LayerType.HARDWARE缓存切换模式后缓存没有失效。解决办法是在自定义View的构造或onDraw中通过Context解析主题属性如果开启了硬件缓存需要在onConfigurationChanged或通过isInEditMode判断后调用invalidate()来强制重绘。override fun onDraw(canvas: Canvas) { val textColor context.resolveThemeColor(R.attr.textPrimary) textPaint.color textColor canvas.drawText(text, x, y, textPaint) }我在实际项目中还遇到过更隐蔽的情况自定义View只在内部一个缓存Bitmap里画颜色模式切换后View会走onDraw但Bitmap内容还是旧的颜色导致画布变色而内容不变。这种时候要记得在切换模式时清掉或者重绘缓存Bitmap不能只依赖invalidate。4.3 切换后出现短暂白屏或黑屏白屏通常来自windowBackground和启动页。日间模式下把windowBackground设成白色没毛病但夜间模式下还保持白色就会在Activity重建的第一帧闪一下白屏。把windowBackground也纳入颜色重载体系在values-night/themes.xml里设置成深色能明显减少闪烁。Android 12及以上还引入了SplashScreen要保证启动页背景色也随模式变化否则冷启动时依然可能出现白闪。用SplashScreen主题替换默认启动背景时同样要准备日间和夜间两份颜色资源。4.4 WebView的白底背景怎么处理WebView加载的HTML如果在日间模式是白底夜间模式下即使App整体深色WebView内容依然是亮的。更重要的是WebView默认背景一旦是白色在页面加载过程中就会闪白。比较简单直接的做法是给WebView设置背景色webView.setBackgroundColor(resolveThemeColor(R.attr.bgPage))如果你能控制HTML可以让前端在页面里监听prefers-color-scheme根据系统模式自动切换深浅样式如果是远程页面也可以通过JavaScript注入的方式给HTML根节点设置一个data-theme属性配合前端提前写好的深色CSS变量。4.5 常见问题速查表问题现象可能原因建议处理状态栏图标看不清windowLightStatusBar未按模式切换在主题里分别设置代码里用WindowInsetsControllerCompat兜底自定义View颜色不变onDraw硬编码颜色或缓存未失效改成解析主题属性切换时invalidate并清除缓存切换瞬间白屏windowBackground是固定白色给夜间主题配置深色windowBackground图片没变图标资源没有drawable-night版本建drawable-night目录放同名夜间图页面某处颜色改不掉布局或代码存在#号硬编码全局搜索#符号替换为主题属性第三方控件内部还是亮的SDK不支持自动夜间切换初始化时传isNight标志或自定义属性覆盖进度条颜色刺眼系统默认indeterminateTint未设置在布局中给ProgressBar加tint这个表我建议放在项目管理文档里每次做完夜间适配都按表过一遍。团队协作时这个表也可以作为验收清单使用。5. 让颜色体系长期可维护5.1 语义化命名和团队约定颜色体系建好之后最难的不是开发而是团队长期维护时不破坏规则。我在团队里推行的规矩很简单新页面不允许在布局XML和代码里直接写#号色值新颜色必须先进colors.xml和attrs.xml再从主题属性引用。为了强制执行可以在build.gradle里配置lint检查或者自定义检测规则把“布局中是否出现#字符”作为错误级别的问题阻断提交。虽然一开始团队成员会不适应但坚持两三个版本后大家就会养成习惯。实际上大部分新需求也不需要新增色值直接从现有颜色体系里取即可整个项目的颜色数量会被控制得非常稳定。5.2 夜间模式自测清单我每次提测前都会拿着这个清单过一遍所有一级页面切换到夜间模式后走查一遍状态栏和导航栏图标颜色是否符合预期弹窗、Dialog、BottomSheet、Toast是否跟随模式输入法弹出后的背景和光标颜色是否正常图片加载组件是否有夜间占位图WebView和地图页面是否出现白底切换模式后页面数据状态是否丢失低内存情况下页面重建后是否恢复正常其中输入法和地图页面是最容易遗漏的。输入法背景一般由输入法自身控制只能尽量适配但光标颜色可以通过主题里的colorAccent控制。地图SDK如果在夜间模式没有开启深色地图样式会把整个地图页面变成一片亮白色。5.3 扩展跟随系统、定时切换与品牌换肤颜色值重载的核心模式其实具备很强的扩展性。当你把颜色全部抽象为主题属性之后再做“跟随系统深色模式”只需要让用户选择MODE_NIGHT_FOLLOW_SYSTEM做“夜间定时切换”只需要用一个定时器在指定时间调整AppCompatDelegate的模式做“品牌换肤”则再写一套新主题把同一组attr指向另一套颜色资源即可。我在最后想强调一个工程上的体会颜色值重载这件事越早做后面越省事。很多项目都是上线两三年后才补深色模式这时才发现几十个页面全都硬编码了颜色改起来牵一发而动全身。如果你是从零开始的新项目建议一开始就把attr体系建好宁可多写一层属性也不要让业务代码里直接出现#号颜色。如果是老项目就从BaseActivity和高频公共组件入手先把底色、主文字色、分割线替换到新体系再逐步向业务页面铺开。这套思路我已经在不同规模的项目上验证过多次做成之后夜间模式就只是一个配置问题而不是一个需要反复救火的历史债务。