
做Android开发这几年隔三差五就会接到同一个需求App要支持日间模式和夜间模式。产品经理说得轻飘飘实际上手才发现这事的核心就一句话——在不同模式下同一套界面要能自动换上另一套颜色值。说得专业点就是颜色值重载同样的资源名放在不同的资源目录里系统根据当前uiMode自动选择加载哪一份。这个机制比很多人想象中简单但也比很多人以为的更坑。简单在于只要你会建目录、写同名资源30分钟就能跑通坑在于如果没有从一开始就规划好命名、主题、属性引用这些东西切换模式后会出现各种顽固的“白底黑字”、“状态栏看不清”、“WebView一片惨白”的破事。这篇文章我把整个方案从头拆到底包含原理、代码、避坑和常见问题速查适合刚接手这个需求的初中级开发者也适合那种“已经做了但总出怪问题”的团队参考。不需要依赖第三方换肤框架就用Android原生资源重载那一套足够干净、可靠、好维护。1. 先说结论夜间模式到底改了什么1.1 系统帮你“重载”资源而不是你写代码判断很多第一次接触夜间模式的人第一反应是写代码判断当前模式然后手动 setBackgroundColor、setTextColor挨个控件去改。这种做法不是不能跑但维护成本极高——每新增一个页面都要手动处理两套颜色赋值漏一处就白一块。Android本身给了一套远比这优雅的方案资源限定符Resource Qualifiers。你可以建立res/values/colors.xml和res/values-night/colors.xml两个目录里面放同名颜色资源但值不同。例如日间模式bg_main定义成#FFFFFF夜间模式bg_main定义成#121212。运行时系统会根据当前夜间模式状态自动从正确的目录加载资源。这个“自动”是Android资源系统在底层完成的不需要你在Java/Kotlin代码里写任何 if/else。你在布局里写android:backgroundcolor/bg_main系统解析资源时发现当前uiMode是UI_MODE_TYPE_NIGHT就直接去values-night里找同名资源。换句话说颜色值重载的本质是资源系统的配置切换而不是代码运行时的逻辑判断。类似的象限还包括values-land横屏、values-zh中文、values-v21Android 5.0、values-sw600dp平板等它们都属于配置限定符。夜间模式只是把night这个限定符用上了而已。1.2 四种模式切换入口与原理要让系统进入或退出夜间状态主流做法是通过AppCompatDelegate。它提供了这几个核心方法AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY);MODE_NIGHT_NO强制日间模式。MODE_NIGHT_YES强制夜间模式。MODE_NIGHT_FOLLOW_SYSTEM跟随系统设置。MODE_NIGHT_AUTO_BATTERY跟随省电模式这个模式在部分新版本上已有废弃趋势不推荐作为默认策略。调用之后AppCompatDelegate会对所有启动了AppCompat的Activity进行重建recreate重新走一遍资源匹配流程界面上的颜色引用就会自动切到另一套。这里有个门槛你的Activity必须继承自AppCompatActivity主题父类必须是Theme.AppCompat.DayNight或Theme.MaterialComponents.DayNight这类带DayNight前缀的主题否则setDefaultNightMode不会触发资源重载。值得一提的还有系统级的UiModeManager.setApplicationNightMode()它和AppCompatDelegate的效果类似但不能自动帮你重建Activity所以实际项目中还是用AppCompatDelegate这套最省心。2. 颜色值重载这是整个方案里最值得抠细节的地方2.1 同名资源两个目录系统自动选边方案落地最小集就是这两份文件。res/values/colors.xml日间默认?xml version1.0 encodingutf-8? resources color namebg_main#FFFFFF/color color nametext_primary#1F1F1F/color color namedivider#E0E0E0/color /resourcesres/values-night/colors.xml夜间覆盖?xml version1.0 encodingutf-8? resources color namebg_main#121212/color color nametext_primary#E2E2E2/color color namedivider#2C2C2C/color /resources布局里所有引用保持完全一样TextView android:layout_widthwrap_content android:layout_heightwrap_content android:text标题 android:textColorcolor/text_primary / View android:layout_widthmatch_parent android:layout_height1dp android:backgroundcolor/divider /实测下来Android资源系统的匹配规则是按“限定符与当前配置最接近”的原则选目录。values-night只在系统判断当前处于夜间模式时生效其他时候走默认values。这个过程发生在资源加载阶段效率极高完全不影响运行时性能。2.2 别再写 color_white 了语义化命名是重载的前提我刚接触夜间模式时踩过一个大坑项目里大量使用color/white、color/black、color/red这种按字面色命名的资源。日间模式没问题到了夜间模式如果强行给white改成深色那所有原本用white的地方就全乱了如果不改界面又是大片刺眼的白底。正确做法是从第一天起就建立语义化颜色体系颜色资源的命名跟随“用途”而不是跟随“色值”。因为values-night里重载的是同名资源的值只有按语义命名才能在夜间模式里安全地换成另一个值。我通常用这套命名表名称日间色值values夜间色值values-night用途bg_main#FFFFFF#121212主页面背景bg_surface#F8F8F8#1E1E1E卡片/浮层背景text_primary#1F1F1F#E2E2E2主标题、正文text_secondary#6E6E6E#9E9E9E辅助说明文字divider#E0E0E0#2C2C2C分割线、描边brand_primary#1565C0#90CAF9品牌色/强调色夜间适当提亮按才不是“白就是白黑就是黑”的思维而是“这个控件需要一种适合阅读的背景色日间给白夜间给深灰”。这样weights划分清晰夜间模式改起来只需要改values-night/colors.xml一个文件不用满项目找#FFFFFF。提示布局和代码里的硬编码色值android:textColor#FFF这种是夜间模式最大的敌人。开发规范里应该明确禁止新代码写硬编码颜色全部走 colors.xml。2.3 属性引用 ?attr/xxx颜色重载的进阶玩法如果你觉得直接引用color/bg_main还不够灵活那就该接触**主题属性Theme Attributes**了。主题属性可以理解为一层间接层布局里写?attr/colorPrimary具体颜色由当前主题解析。如果主题被替换/覆盖颜色跟着变甚至不需要动资源目录。典型的做法是先在attrs.xml里自定义属性resources attr nametextPrimary formatcolor / attr namebgSurface formatcolor / /resources然后在主题里给这些属性赋值。日间主题和夜间主题分别继承不同的父主题但属性名一致res/values/themes.xmlstyle nameTheme.App.Day parentTheme.AppCompat.DayNight.NoActionBar item nametextPrimarycolor/text_primary/item item namebgSurfacecolor/bg_surface/item !-- 其他属性 -- /styleres/values-night/themes.xmlstyle nameTheme.App.Day parentTheme.AppCompat.DayNight.NoActionBar item namebgSurfacecolor/bg_surface_night/item /style布局和代码里统一用属性引用LinearLayout android:background?attr/bgSurface /LinearLayoutTypedValue tv new TypedValue(); if (getTheme().resolveAttribute(R.attr.textPrimary, tv, true)) { int color tv.data; }这种方式比直接引用color更适合封装通用组件。比如你做一个通用底部分割线View它不需要知道自己属于哪个页面只要读取?attr/divider就行。主题换它跟着换。如果后续要做多套主题不只是日/夜还有高对比度、护眼绿等属性引用这套方案的扩展性会明显优于裸引用颜色资源。2.4 主题覆盖 ThemeOverlay动态临时改色有些场景下你不希望整个App的主题变化只想临时给某个控件区域换颜色。这时可以用ThemeOverlay。它和普通主题的区别在于它不指定 parent只覆盖当前有效的主题属性。比如你有一个特殊页面夜间模式下也想保持品牌浅色背景style nameThemeOverlay.App.LightArea parent item nameandroid:background#FFFFFF/item item nametextPrimary#1F1F1F/item /style在布局中直接应用到根ViewLinearLayout android:themestyle/ThemeOverlay.App.LightArea ...这个机制常用在Dialog、BottomSheet、自定义弹层上。它们经常和全局主题不同步用ThemeOverlay可以局部覆盖而不需要重新设计一套全局主题。上面的方案实际上就是“按需覆盖颜色值”的另一种形式在我做多主题加强版的时候用过很多次非常顺手。日常日/夜切换其实用不到但知道它存在遇到偏门需求时能少走弯路。3. 实操从零搭一个支持日间/夜间切换的App3.1 第一步主题与基础配置核心工程配置我推荐用Theme.Material3.DayNight或Theme.AppCompat.DayNight二选一。我自己的项目用的是Material3因为Material3自带一套完整的颜色系统colorPrimary、colorSecondary、surface等夜间模式天然适配良好且组件样式比AppCompat更现代。先在res/values/themes.xml里定义应用主题style nameTheme.MyApp parentTheme.Material3.DayNight.NoActionBar item namecolorPrimarycolor/brand_primary/item item namecolorOnPrimarycolor/brand_on_primary/item item nameandroid:statusBarColorcolor/bg_main/item item nameandroid:navigationBarColorcolor/bg_main/item item nameandroid:windowLightStatusBartrue/item /stylevalues-night/themes.xml里保持同名覆盖部分值style nameTheme.MyApp parentTheme.Material3.DayNight.NoActionBar item namecolorPrimarycolor/brand_primary_night/item item nameandroid:statusBarColorcolor/bg_main/item item nameandroid:windowLightStatusBarfalse/item /style注意到android:windowLightStatusBar这个属性很重要。它控制的是状态栏上的系统图标是深色还是浅色。日间背景是白系统图标必须用深色夜间背景是黑系统图标必须用浅色。这个属性不能简单写死需要跟着夜间模式一起重载。还有Manifest里的主题引用application android:themestyle/Theme.MyApp ...3.2 第二步颜色体系拆分与资源重载我在实际项目中会把颜色需求拆成五组每组负责一类场景这样重载时思路非常清晰基础背景色bg_main页面底、bg_surface卡片浮层、bg_input输入框/搜索框。文字色text_primary、text_secondary、text_hint、text_inverse深色背景上的反白文字。分割线与描边divider、stroke、shadow夜间阴影基本不可见多用描边替代。交互状态色brand_primary、brand_pressed、brand_disabled、ripple水波纹。功能色success、warning、error、info。这部分通常日夜两套只需要微调明度比如夜间模式错误红稍微亮一点避免在深色背景下看不清。每个颜色同时维护在values/colors.xml和values-night/colors.xml保持同名。建议给每个颜色写注释标注使用场景这样几个月后回来维护也不会一脸懵。!-- 日间模式 -- color namecolor_error#D32F2F/color !-- 夜间模式 -- color namecolor_error#F05252/color提示第二次重载时不要只盯着colors.xmlDrawable资源里如果有硬编码色值也要处理。比如一个圆角矩形的shapesolid android:color#FFFFFF/是重灾区。正确做法是solid android:colorcolor/bg_surface/。3.3 第三步切换逻辑与偏好存储切换模式的核心API是AppCompatDelegate.setDefaultNightMode()。我通常这样设计入口设置页面放一个单选或开关点击后保存偏好到 SharedPreferences然后立即生效。public class ThemePrefs { private static final String KEY_MODE theme_mode; static int loadMode(Context ctx) { return ctx.getSharedPreferences(app_theme, Context.MODE_PRIVATE) .getInt(KEY_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); } static void saveMode(Context ctx, int mode) { ctx.getSharedPreferences(app_theme, Context.MODE_PRIVATE) .edit().putInt(KEY_MODE, mode).apply(); } }在Application的onCreate里尽早调用避免启动时先白后黑闪一下public class App extends Application { Override public void onCreate() { super.onCreate(); int mode ThemePrefs.loadMode(this); AppCompatDelegate.setDefaultNightMode(mode); } }切换入口public void switchToMode(int mode) { ThemePrefs.saveMode(this, mode); AppCompatDelegate.setDefaultNightMode(mode); // Activity 会自动重建无需手动 recreate }这里有个细节setDefaultNightMode会异步触发Activity重建如果你在切换后马上读取了某些View状态需要先保存再恢复。我的习惯是对有状态的页面比如列表滚动位置、输入框内容在点击切换前先保存到ViewModel或SparseArray重建后再写出否则用户切个模式回来输入的内容全没了。3.4 第四步状态栏、控件与第三方库的适配状态栏和导航栏是新手最容易翻车的地方。除了前面说的windowLightStatusBar现在很多项目还用了EdgeToEdge全屏透明系统栏。一旦启用EdgeToEdge系统栏颜色就不能再通过statusBarColor控制而是推荐用windowLightStatusBar 根布局内部加顶部预留padding的方式处理。这个方案下夜间模式的重载焦点就变成了根布局的背景色和windowLightStatusBar两个值。Material控件在DayNight主题下基本都能自动适配比如MaterialButton、MaterialCardView、EditText的默认样式都会跟随主题。但如果你的项目同时混用了一些老控件或第三方库就得逐个检查是否有硬编码颜色、是否有固定textColor。我在项目里遇到最多的问题来自第三方选择器和时间控件几十个属性里总有几个写死的白色背景。这个只能见一个改一个也可以在一开始选第三方库的时候优先选官方Material或声明支持深色模式的库。WebView是另一个重灾区。WebView完全不受values-night资源重载的控制HTML页面里的白色背景和黑色字体不会自己变。最简单的兜底方案是在加载页面时注入一段夜间CSSString css body{background:#121212 !important;color:#E2E2E2 !important;}; String js javascript:(function(){ var styledocument.createElement(style); style.innerHTML css ; document.head.appendChild(style); })(); webView.evaluateJavascript(js, null);这个方法不完美但能覆盖大部分页面。如果WebView加载的是自家H5页面建议前后端约定通过UA或JSBridge传当前模式给H5让前端做真正的适配。3.5 延伸Compose 与动态取色一句话说清如果你的新项目已经开始用Jetpack Compose那颜色重载的思路类似但实现载体不同。Compose用的是isSystemInDarkTheme()或MaterialTheme(colorScheme darkColorScheme())通过一个布尔值选择整套ColorScheme本质上还是“同一套语义色两套值”。Material3组件全面使用ColorScheme所以适配起来比View体系要顺畅得多。这里不再展开知道思路一致即可。另外Android 12 的Material You动态取色Wallpaper-based color也能和夜间模式共存。它会根据壁纸生成一套dynamic color其中包含深色版本。启用取色后夜间模式会自动使用动态取色的深色方案。原理不同但在开发层面你依然只需要维护好语义化颜色这套体系实现成本很低。4. 踩坑记录常见问题与排查速查表4.1 最常见的问题切换后界面纹丝不动这个我几乎每周都能在群里看到一遍。切换了夜间模式App一点变化没有甚至闪退。排查顺序如下第一检查Activity是否继承AppCompatActivity。如果用的是Activity或FragmentActivitysetDefaultNightMode是不生效的因为实现依赖AppCompat的组件容器。第二检查主题父类。主题必须继承Theme.AppCompat.DayNight系列或Theme.Material3.DayNight系列。如果用了Theme.MaterialComponents.Light这种不带DayNight的主题系统不会自动切模式。第三检查是否所有颜色都走了资源引用。比如你在代码里这样写view.setBackgroundColor(Color.WHITE);这就是彻底绕过资源重载系统怎么切它都是白色。正确的写法要么是view.setBackgroundResource(R.color.bg_main)要么用ContextCompat.getColor()获取资源色。第四看看有没有在Application里提前调用。setDefaultNightMode在Application中越早调用越好如果在Activity的onCreate之后调用虽然也会生效但可能出现首帧闪烁。4.2 夜间模式下的系统栏与窗口背景问题状态栏图标“隐身”是特别常见的问题。日间模式下背景是白色如果windowLightStatusBarfalse系统图标是浅色白底白字啥也看不见。夜间模式下背景是黑色如果windowLightStatusBartrue黑底黑字同样啥也看不见。这个属性在values和values-night里各配一份并配合背景色重载。具体写法前面已经给过这里不再重复。另一个隐蔽问题是启动闪白。App冷启动时系统先加载windowBackground如果windowBackground是亮色在夜间模式下启动瞬间会先白一下。解决方法是给主题配一个跟随夜间模式重载的android:windowBackgrounditem nameandroid:windowBackgroundcolor/bg_main/item因为bg_main本身已经跟随values-night重载所以启动背景也会自动跟着变。这个细节很多团队忽略做了夜间模式却还在亮屏闪白体验非常出戏。4.3 WebView/图片/动态图标等容易被忽略的角落图片资源也是重载的重点。有些App日间用深色logo夜间应该用浅色logo做法是建立res/drawable和res/drawable-night两个目录放同名不同色图片或用res/drawable/icon_logo.png与res/drawable-night/icon_logo.png。Android 8.0之后推荐用AdaptiveIcon配合monochrome属性可以自动根据主题取色这能省不少美工切图的功夫。但要注意AdaptiveIcon的monochrome只在Android 13 完整支持低版本需要提供传统回退图片。电子签名、手势解锁这类需要绘制颜色的场景也要注意别用硬编码。如果代码里直接Paint.setColor(0xFF000000)夜间模式下画出来的东西在深色背景上就消失了。建议从?attr/textPrimary中解析颜色再绘制。TypedValue typedValue new TypedValue(); getTheme().resolveAttribute(R.attr.textPrimary, typedValue, true); paint.setColor(typedValue.data);4.4 完整问题速查表问题现象可能原因解决方案切换后界面无变化Activity未继承AppCompatActivity改为继承AppCompatActivity切换后界面无变化主题父类不是DayNight改用Theme.AppCompat.DayNight或Theme.Material3.DayNight切换后界面无变化代码硬编码了Color.WHITE改为ContextCompat.getColor(R.color.xxx)状态栏图标不可见windowLightStatusBar未跟随模式重载values和values-night各配置一份启动闪白windowBackground未参与重载windowBackground引用跟随重载的颜色资源WebView白底黑字WebView不走values-night资源注入夜间CSS或前后端约定传模式切换后输入内容丢失Activity重建导致状态丢失切换前保存状态到ViewModel/Preferences夜间模式Dialog还是白色Dialog样式未走AppCompat主题使用AppCompatDialog并使用DayNight主题第三方库控件颜色异常第三方库不支持深色资源使用ThemeOverlay局部覆盖或放弃该库4.5 一些实操心得与测试建议根据我的经验夜间模式这种东西从需求提出那一刻就应该开始准备而不是等所有页面做完再统一改。统一改造的劳动强度是逐页面顺手改造的三倍以上。在新页面开发时把颜色从资源里引出来写语义化颜色名成本几乎为零但收益巨大。另一个容易被忽略的点是夜间模式测试时不要只看自己App里的开关还要测跟随系统模式。因为很多用户的系统设置是自动切换特别是晚上打开省电模式有些手机会自动进入深色模式。如果App在FOLLOW_SYSTEM下切换不及时用户会一口咬定“App有问题”。这种问题通常是Activity被系统杀死后没有正确恢复导致的解决方案依然是尽早调用setDefaultNightMode并保存偏好。测试设备上不要只测模拟器找一台真机把系统设置的“深色模式”开关和App内开关一起测。我遇到过一次只有在真机才复现的系统栏变色问题模拟器上一切正常推测跟各家ROM的系统栏策略有关。工具方面Android自带的Layout Inspector在调试颜色引用时特别好用可以看到View的每个属性实际解析成的色值以及来源资源路径一眼就能看出某个颜色是哪个文件里来的排查硬编码色值非常高效。最后再分享一个小技巧我在多个项目里把夜间模式做成用户可选的“三态”设置跟随系统、亮色、暗色。这比单纯提供“夜间开关”更稳妥因为很多用户其实并不想让App跟着系统走他们更愿意手动控制。三态的实现成本只比两态多一点点加一个单选分组存一个int切换时判断当前模式。另外如果团队里后端同学做的长列表接口返回了HTML富文本记得提醒他们富文本的颜色也要支持深色模式否则一个白色背景的HTML片段插在深色页面里用户体验直接崩盘。前端H5和App端约定的模式协议从一开始就定好后面能省很多来回扯皮。说白了日间/夜间模式这件事看起来是换颜色实际上是换一套资源策略。只要按资源重载的思路走再盯紧几个容易翻车的位置这功能做起来并不难。希望这篇文章能帮你少走一些我走过的弯路。