
一句话深色模式在 Android 上从来不是给界面刷一层黑漆这么简单。接触到真实反馈后你会明白它背后是一整套资源加载、主题切换和运行时状态管理的机制。本文会从系统原理讲到落地配置从方案选型讲到翻车排坑适合正要给 App 接入黑暗模式、或者接入后发现各种漏网之白的 Android 开发者阅读。1. 先搞清楚系统到底在做什么uiMode 背后的资源加载机制1.1 为什么黑暗模式不是换皮肤很多团队把黑暗模式当成一个运行时变量用户打开开关App 读取这个值然后手动把所有背景色替换成深色。这种思路不能说完全错但很容易漏控件、漏页面而且和系统级切换完全脱节。Android 官方的黑暗模式本质上是资源限定符机制在起作用。系统通过Configuration.uiMode中的UI_MODE_NIGHT_MASK位去判断当前是白天UI_MODE_NIGHT_NO还是夜间UI_MODE_NIGHT_YES。这个判断在资源加载阶段就已经完成当系统处于夜间模式时values-night/、drawable-night/、layout-night/这些目录里的资源会被优先选中找不到对应目录时才会回退到默认的values/、drawable/。这就意味着一个适配良好的黑暗模式 App并不是在运行时算出应该用什么颜色而是利用系统资源选择机制让每个资源在亮色和暗色环境下各就各位。布局里如果写的是android:background#FFFFFF那不管怎么切换主题它永远都是白色只有把颜色抽成语义属性例如?attr/colorBackground或者自定义属性系统才能在不同模式下自动给你不同的值。1.2 模式的切换会触发什么系统切换黑暗模式时Configuration会变化默认情况下当前 Activity 会被重建。你可以理解成系统在用一套全新的资源目录重新加载界面这个过程和屏幕旋转是同类事件。如果不想让页面重建可以在 Manifest 中声明android:configChangesuiMode但这样做意味着你要自己在onConfigurationChanged回调里处理所有 UI 刷新否则界面上的颜色、背景、文字都会停留在旧状态反而更麻烦。应用内部发起的切换走的是AppCompatDelegate.setDefaultNightMode(...)。这个方法会在应用进程内改变 uiMode 的状态并触发同样的资源配置切换。它支持的几种模式后面会详细说但你要有一个基本认知无论哪种切换方式页面的重建或刷新都在所难免关键是你要在切换前后把状态保存好。2. 三个主流方案我在实际项目里怎么选2.1 Force Dark最小成本但不是万能Android 10 开始系统提供了一种Force Dark的自动适配机制。你不需要写values-night只需要在主题里开启item nameandroid:forceDarkAllowedtrue/item系统会在运行时分析你的亮色界面尝试把背景、文字、控件颜色自动映射成深色。对于大多数使用系统控件、标准主题颜色、没有大量自定义绘制的页面效果其实相当不错尤其是那种历史包袱很重、一时间改不完所有布局的项目先用 Force Dark 顶上去能让用户立刻告别深夜被亮瞎的痛苦。但它有明显的边界自定义View的onDraw里画出来的颜色、Bitmap图片、WebView里的内容它都没办法处理。一张白色底的图片放在深色背景下该刺眼还是刺眼。而且它依赖系统版本Android 9 及以下不受支持。我的态度是它可以作为一个临时兜底适合先止血不适合当作最终方案。2.2 AppCompatDelegate DayNight官方推荐的兼容路线这是我现在做新项目的默认选择也是本篇文章接下来要重点展开的方案。思路是让 App 的主题继承Theme.AppCompat.DayNight系列然后在不同资源目录里提供对应的色板。它背后的核心类是AppCompatDelegate这套方案不仅能跟随系统切换还能让你在 App 内手动切到夜间模式并且向下兼容到比较老的 Android 版本。DayNight 的厉害之处在于它会根据当前 uiMode 自动决定使用哪一套主题资源。配合values-night目录你可以非常细腻地控制每一个页面在夜间模式下的表现。它也解决了系统控件默认色的问题——比如AlertDialog、ProgressBar、Switch只要主题是 DayNight 体系它们多数会跟着变。2.3 自己监听 uiMode适合有自研色彩管线的老项目有些项目早就有自己独立的一套主题切换框架颜色都不是写在 XML 里而是存在服务端下发的配置或者本地数据库里。这种情况再去套 DayNight反而要动很多底层逻辑。可以自己监听Activity.onConfigurationChanged在回调里重新读取当前模式然后刷新 UI。好处是完全可控不会受资源目录的限制所有颜色都能走自己现有的分发逻辑。代价是工作量极大你能想到的每一个控件、每一条文字颜色、每一个背景都要自己处理一遍而且后续新页面必须严格遵守这套规则。方案本身不差但它不是实现黑暗模式的通用路径更像是一套需要长期维护的定制工程。三个方案我用表格做一次对比方案系统兼容适配成本自定义控件表现适合场景Force DarkAndroid 10极低不稳定可能出反色历史项目临时过渡DayNight向下兼容中等需梳理色板可控需语义化颜色绝大多数新项目自研追踪全版本高完全可控已有自研主题管线的项目一句话总结没有历史包袱就用 DayNight有历史包袱先上 Force Dark 再逐步迁移有自研主题管线就自己监听但别轻易这么做。3. 用 DayNight 把应用整体拉进黑暗模式的标准动作3.1 主题继承与 night 资源目录第一步先确认依赖在build.gradle里使用 AppCompat 库版本尽量新一些implementation androidx.appcompat:appcompat:1.6.1然后让 Application 和所有 Activity 的主题继承 DayNight。大多数项目是统一在values/styles.xml里定义style nameAppTheme parentTheme.AppCompat.DayNight.NoActionBar item namecolorPrimarycolor/brand_primary/item item namecolorPrimaryDarkcolor/brand_primary_dark/item item namecolorAccentcolor/brand_accent/item /styleDayNight 系列主题在夜间模式下的默认行为是系统栏背景、窗口背景、文本颜色等都会自动使用对应模式的值。你不需要为每个页面单独设置深色背景。接着创建values-night/colors.xml把默认色板中需要变化的值再定义一遍。比如默认的colorPrimary是偏深蓝夜间模式下可以调亮一点否则深色背景上深色主按钮的对比度会不足。3.2 颜色语义化别直接写死颜色这是整个适配过程中最琐碎也是最重要的一步。我看见过很多团队主题切好了资源目录也建好了但布局里全是这种写法TextView android:textColor#333333 android:background#FAFAFA /这种硬编码颜色在亮色模式下没问题但夜间模式下这些控件完全不听系统指挥依然保持原来的样子。正确的做法是把可变的颜色抽成语义属性。系统提供的?attr/colorBackground、?attr/colorSurface、?attr/colorOnBackground、?attr/colorControlNormal等覆盖了大多数场景。比如根布局背景用?attr/colorBackground卡片用?attr/colorSurface普通文字用?attr/colorOnBackground主要文字用?attr/colorControlNormal。如果是自定义控件需要自己定义语义属性可以在attrs.xml里声明attr namecontentPrimaryColor formatcolor /在亮色主题里设成深灰在夜间主题里设成浅灰控件里通过TypedArray读取而不是直接写Color.GRAY。这样做的价值在于你从每个页面自己控制颜色变成了统一体系控制颜色后面新增页面根本不用单独适配。3.3 手动切换与跟随系统需求通常有几种形态跟随系统、只亮、只暗以及由用户自己选择。跟随系统是最省心的应用里也不需要一个开关完全看用户的系统设置。如果你需要一个应用内三态选项靠的就是AppCompatDelegatewhen (selectedMode) { 0 - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) 1 - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) 2 - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) }MODE_NIGHT_FOLLOW_SYSTEM是跟随系统MODE_NIGHT_NO强制亮色MODE_NIGHT_YES强制暗色。需要提醒的是这个设置是进程级的所以最好在Application.onCreate里读取持久化选项并调用一次同时把选择存到SharedPreferences里每次冷启动都恢复。如果用户改完选项希望立刻看到效果可以在当前页面执行recreate()但别忽略状态保存的问题后面会专门讲。3.4 新增页面容易漏掉的地方主题和色板搭好之后正常的新页面开发只要布局遵守语义化颜色规则基本都能自动适配。最容易漏的是两类一类是直接写在 Java/Kotlin 代码里的颜色。比如在代码里textView.setTextColor(Color.parseColor(#666666))这种颜色写死后是和资源系统无关的夜间模式下不会变。正确做法是从资源或属性里取例如getColor(R.color.content_secondary)然后在values-night/colors.xml里覆盖。另一类是从网络或服务端拿下来的颜色值比如运营后台配置的页面背景色、文字色。客户端无法预料运营配的到底是亮还是暗只能在展示前判断当前模式再决定是否要做降饱和、压暗处理。这个不是纯粹的黑暗模式适配问题但涉及到了就很容易被忽略。4. 现场翻车记录这些地方在夜间模式下原形毕露4.1 WebView 白底问题DayNight 对原生的布局、控件、主题都有很好的效果但WebView是一个例外。WebView 加载的 HTML 内容和 Android 原生资源系统是隔离的values-night里的资源影响不到 HTML 的 CSS。结果就是页面整体已经变暗了页面里嵌了一个白得刺眼的 WebView。处理这个问题的通用思路是在 WebView 里注入深色样式。以本地 HTML 页面为例可以在onPageFinished里注入一段 JavaScriptbinding.webView.webViewClient object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { val isNight AppCompatDelegate.getDefaultNightMode() AppCompatDelegate.MODE_NIGHT_YES if (isNight) { view?.loadUrl(javascript:(function(){ document.body.style.backgroundColor#121212; document.body.style.color#EEEEEE; var linksdocument.getElementsByTagName(a); for(var i0;ilinks.length;i){links[i].style.color#90CAF9;} })()) } super.onPageFinished(view, url) } }更稳妥的做法是在应用内维护两套 CSS 模板根据当前模式在loadDataWithBaseURL时选择不同版本。这样不仅不会出现页面加载后再闪变的问题对图片亮度、表格边框这些复杂样式也更好控制。至于加载外部链接的 WebView建议先判断页面prefers-color-scheme支持情况不支持的只能靠注入 CSS 或者明确提示用户页面内容为亮色展示。4.2 图片资源在深色背景下不协调这大概是适配后期最让人头疼的一类问题。一张以白色为底的产品图、一张透明背景的 PNG Logo放到深色背景上要么太刺眼要么边缘发灰。更常见的是代码里setImageResource固定加载一张图片没有考虑夜间模式是否需要替换成深色版本。如果你的图片资源本身有两种设计可以直接放在drawable-night目录并在布局里引用同一个drawable/xxx系统会自己选。注意这里的drawable-night只对drawable/资源引用生效对代码里setImageResource(R.drawable.xxx)同样生效因为它走的是资源解析。如果是网络图片就没法在资源层面解决了。常见做法是在图片加载库的展示回调里判断当前模式再给ImageView盖一个半透明黑色遮罩或者降低亮度。比如 Glide 加载完给 Bitmap 调一个亮度系数不过这样会额外增加 CPU 开销适合关键大图不适合列表里的缩略图。4.3 对话框、状态栏与系统控件的漏网之鱼原生AlertDialog、DatePickerDialog等只要主题继承 DayNight基本都能自动适配。但如果你在代码里直接new AlertDialog.Builder(this)而这个Activity的 theme 在setContentView之后被手动改成别的主题弹窗就可能出现半亮半暗。稳妥做法是弹窗统一使用当前 Activity 的 theme不要在弹窗构造时传入自定义的亮色 Theme。状态栏和导航栏是另两个容易出问题的地方。夜间模式下状态栏图标应该是浅色因为你背景变深了亮色模式下状态栏图标应该是深色。系统默认并不总是帮你处理好特别是使用非标准主题时。建议在values/styles.xml和values-night/styles.xml里区分设置!-- values/styles.xml -- item nameandroid:windowLightStatusBartrue/item !-- values-night/styles.xml -- item nameandroid:windowLightStatusBarfalse/item如果用兼容库可以写WindowInsetsControllerCompat来动态控制。窗口背景也值得检查一下有些项目为了启动速度给windowBackground写死了白色结果每次切到夜间模式页面重建瞬间会闪一下白屏别问我怎么知道的。4.4 切换回调里直接刷新 UI 的时机问题声明了android:configChangesuiMode的页面系统切换后不会重建而是回调onConfigurationChanged。很多人在这里直接遍历 View 改颜色但经常发现改了背景色子控件里的文字颜色还是不对。原因是你在onConfigurationChanged里拿到的所有 View 引用还是旧资源环境下创建的。它们持有的颜色是上一个模式的资源值不是切换后的。理论上你可以在回调里手动重新绑定所有颜色但这个工作量不亚于重新加载一遍页面。如果你只是极少数页面声明了configChanges我建议在这些页面里直接调recreate()然后从savedInstanceState恢复状态让整个页面统一重建反而比零散刷新更省心。5. 把黑暗模式做成好体验的进阶细节5.1 保存页面状态避免 recreate 丢数据前面反复提到recreate()但很多开发者第一次做完黑暗模式后会发现切换模式时用户正在填写的表单内容、选中的 tab、滚动的位置全丢了。这是因为recreate()走的是正常的 Activity 重建流程Android 只会保存实现了onSaveInstanceState的 View 状态。像EditText的内容、RecyclerView的滚动位置这些只要设置了id通常能自动恢复。但你在内存里持有的列表数据、网络请求结果、临时选中的对象不会自动保存。我在接黑暗模式时习惯把这些状态放进ViewModel或者至少在recreate()之前把关键数据写到savedInstanceState。如果你使用的是 Navigation 组件或者 Fragment 场景这种问题会更明显因为 Fragment 的重建开销更大。强烈建议在做模式切换功能之前先测一遍填到一半的表单切换模式。5.2 深色模式不是单纯的反转颜色对比度要重新考虑很多人第一版把深色模式做成亮色背景变成纯黑、文字变成纯白结果视觉上很僵硬而且在低亮度屏幕下看久了依然疲劳。实际上深色模式推荐的背景色并不是#000000尤其 OLED 屏幕纯黑色确实能省电但大面积纯黑会让界面层次感完全消失。比较舒服的做法是背景用深灰色#121212卡片或浮层用略浅一点的灰#1E1E1E或#2D2D2D文字用#E0E0E0而不是#FFFFFF。另外夜间模式下文字与背景的对比度不能跟亮色模式一样。比如亮色模式下次要文字#999999在白色背景上还能看清但同样的灰放在深色背景上对比度差容易看不清。我一贯的做法是把次要文字在夜间模式下调亮一档例如从#999999改成#BDBDBD既保留层级感也保证可读性。这里的判断标准没有强制值但至少要在暗光环境下亲自把主要页面过一遍不能只盯着设计稿看。5.3 不支持的第三方组件怎么办不是所有第三方库都对黑暗模式友好。有些图表库、地图组件、富文本编辑器内部是自绘或者有独立的主题体系你设置外层背景根本管不到它们。遇到这种情况我会在库里寻找有没有官方深色模式配置没有的话先用一个半透明的深色蒙层或者把组件背景调整成可接受的灰色暂时强行融入实在不行的就把这个页面提示为暂不支持或者在用户切换模式时弹一个说明当前页面部分内容将保持亮色展示。宁可明确告诉你哪些地方没适配也不要让用户觉得整个 App 在闪烁、存在色块断层。把不支持的组件统一记录下来后续再推动库的升级或者自研替换。5.4 首屏启动与冷启动时的模式恢复最后说一个体验层面容易忽视的地方。如果你只是把用户的选择存下来了没有在Application.onCreate里调用setDefaultNightMode那么冷启动时应用会先按系统默认模式渲染一版界面然后才跳到夜间模式用户会看到一次明显的闪变。正确做法是在Application.onCreate的最开始读取持久化选项并设置override fun onCreate() { super.onCreate() val mode settings.getPreferredMode() AppCompatDelegate.setDefaultNightMode(mode) }如果你的主题没有正确继承 DayNight或者 Application 里设置了主题之后又被 Activity 给覆盖了也会出问题。判断标准很简单冷启动后直接就是目标模式首页不该有任何亮-暗的过程。在我实际项目经验里黑暗模式适配真正难的从来不是那套切换 API而是全局颜色体系的梳理和资源管理的纪律性。方案不难选DayNight 加语义化颜色基本能覆盖绝大多数场景难的是每个页面都克制住写死颜色的冲动难的是把 WebView、图片、第三方组件这些编外成员逐个驯服。如果你正准备动手建议先花一天时间把现有颜色全部整理成语义属性后面就会顺很多。