适配 Android Q 的暗黑模式
立泉DarkMode是Google在Android Q中引入的全局暗黑模式,与iOS 13一样,Android也开始拥有系统级明暗主题。每一个App都可以选择启用或关闭暗黑模式,或者选择跟随系统设置自动在普通模式和暗黑模式之间切换。
在中文社区搜索暗黑模式会发现很多人并没有理解DarkMode和Theme之间的关系,所以我依旧认为阅读官方文档是学习Android最基本也是最有效的方式。Android团队会把新特性写在文档中,希望工作成果能详细准确的传递给开发者,包括设计思路、适配方式和向前兼容方案。很多微妙细节会在社区的观点传递中丢失,如果只能看到质量很差的末端“教程”,知其然不知其所以然,必定一头雾水。
而很多Bug,如果清楚知道自己调用的API会对当前组件的生命周期产生什么影响,以及如何解决可能由此引起的连锁问题,这些Bug在某种程度上是不应该存在的,或者是可以被预期的,再或者,当它出现的时候能很快反应出问题所在。可以调侃自己每天在写Bug,但调侃其实只应该是调侃。
DarkMode is not a dark theme
"暗黑模式不是一个暗色主题",在Android Q之前已经有一套主题机制,可以定义一系列配色方案来构建Theme,其中包括暗色Theme,但它和暗黑模式有本质区别。
普通模式包含很多Theme,暗黑模式更像是普通模式的镜像,是相对普通模式而言的概念。普通模式每一个Theme都应该在暗黑模式找到对应的映射,它们都是Theme的集合,并且2个模式之间的Theme一一对应。
Theme
在暗黑模式出现之前需要使用<style>定义Theme,可以在AndroidManifest文件中定义Application和Activity要使用的Theme,也可以在Activity启动时动态设定当前Activity要使用的Theme来实现更大的灵活性。
首先将要使用的一些属性,如颜色,抽象出来,定义为通用的attr属性:
/values/attrs.xml
<resources>
<!-- 定义各个Theme都要使用的通用属性 -->
<!-- 标题颜色 -->
<attr name="commonTitleColor" format="color"/>
<!-- 背景颜色 -->
<attr name="commonBgColor" format="color"/>
</resources>
定义不同Theme下这些属性需要的值,这里以RED和GREEN两个Theme为例:
/values/colors.xml
<resources>
<!-- 定义RED和GREEN主题下需要用到的值 -->
<color name="commonTitleRed">#FF0000</color>
<color name="commonTitleGreen">#00FF00</color>
<!-- 2个主题都使用同样的白色背景 -->
<color name="commonBg">#FFFFFF</color>
</resources>
定义RED和GREEN主题,并绑定属性和该主题对应的值:
/values/styles.xml
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<!-- 红色主题 -->
<style name="AppTheme.RED">
<item name="commonTitleColor">@color/commonTitleRed</item>
<item name="commonBgColor">@color/commonBg</item>
</style>
<!-- 绿色主题 -->
<style name="AppTheme.GREEN">
<item name="commonTitleColor">@color/commonTitleGreen</item>
<item name="commonBgColor">@color/commonBg</item>
</style>
</resources>
在AndroidManifest中指定Application或Activity要使用的Theme:
<!-- 指定所有Activity都要使用的Theme -->
<application
...
android:theme="@style/AppTheme">
<!-- 也可以为单个Activity指定Theme -->
<activity android:name=".MainActivity"
android:theme="@style/AppTheme.RED"/>
</application>
或者在Activity启动时动态的指定该Activity要使用的Theme:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 取出保存的用户主题设置
val theme = dataDao.getTheme()
// 在Activity创建时,为这个Activity设置对应的Theme
if (theme == CusTheme.RED) {
setTheme(R.style.AppTheme_RED)
} else {
setTheme(R.style.AppTheme_GREEN)
}
// 注意这里setTheme要放在setContentView前面
setContentView(R.layout.activity_demo)
}
然后就可以在Layout中使用这些在不同主题中定义好的颜色属性了:
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="这是一段文字"
android:textSize="15sp"
android:textColor="?attr/commonTitleColor" />
在Layout的Attributes中选择要预览的主题,该属性就会使用选定主题的颜色值进行预览:

要实现根据用户的点击选择动态应用Theme也很简单,用户点击后,通知Activity重建,并在重建时设置Theme即可。应注意由Activity的重建可能导致的部分组件状态丢失和Fragment重复创建等问题,它们都和Activity在异常状态下的状态保存、恢复机制有关。
// 调用Activity实例的recreate()方法即可通知Activity重建
activity.recreate()
DarkMode
Google在Android Q中引入全局的暗黑模式,上面已经提到,暗黑模式就是普通模式下主题的映射,所以Android提供了一个新的资源限定符-night。比如把所有暗黑模式下要用到的颜色都定义在values-night/color.xml中,那么在切换到暗黑模式之后,Android就会从这个文件中读取配置的主题颜色。
要使用暗黑模式,主题必须继承自Theme.AppCompat.DayNight或Theme.MaterialComponents.DayNight,依然用上面的这个例子:
/values/styles.xml
<resources>
<!-- AppTheme必须继承自DayNight -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<!-- 红色主题 -->
<style name="AppTheme.RED">
<item name="commonTitleColor">@color/commonTitleRed</item>
<item name="commonBgColor">@color/commonBg</item>
</style>
<!-- 绿色主题 -->
<style name="AppTheme.GREEN">
<item name="commonTitleColor">@color/commonTitleGreen</item>
<item name="commonBgColor">@color/commonBg</item>
</style>
</resources>
RED和GREEN2个主题在普通模式下背景是白色,在暗黑模式下,背景应该是黑色,只需要在/values-night/color.xml文件中定义暗黑模式下commonBg的颜色为黑色即可:
/values-night/colors.xml
<resources>
<!-- 这里会继承、覆盖/values/color.xml的同名属性,所以如果暗黑模式下颜色没有变,没有必要在这里重新定义 -->
<!-- <color name="commonTitleRed">#FF0000</color> -->
<!-- <color name="commonTitleGreen">#00FF00</color> -->
<!-- 2个主题在暗黑模式下都使用同样的黑色背景 -->
<color name="commonBg">#000000</color>
</resources>
这样一来,暗黑模式同样拥有普通模式下的RED和GREEN2个主题,可以非常灵活的配置这两个主题在暗黑模式下的颜色。
切换暗黑模式也非常简单,只需要借助AppCompat类库提供的AppCompatActivity和AppCompatDelegate,就可以实现在暗黑模式的开、关和跟随系统这三种状态间切换。需要注意的是,因为全局的暗黑模式是在Android Q上才引入的,所以跟随系统在低版本中无效,而AppCompat类库实现的暗黑模式的开和关则向前支持所有Android版本。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 取出保存的用户主题设置
val theme = dataDao.getTheme()
// 取出保存的用户暗黑模式设置
val darkMode = dataDao.getDarkMode()
// 在Activity创建时,为这个Activity设置对应的Theme
if (theme == CusTheme.RED) {
setTheme(R.style.AppTheme_RED)
} else {
setTheme(R.style.AppTheme_GREEN)
}
// 注意这里setTheme要放在setContentView前面
setContentView(R.layout.activity_demo)
// 设置要使用的暗黑模式
if (darkMod == CusDarkMode.On) {
// 暗黑模式开启
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
} else if (darkMod == CusDarkMode.Off) {
// 暗黑模式关闭
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
} else {
// 暗黑模式跟随系统设置
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
}
从AppCompat v1.1.0开始,执行setDefaultNightMode()会自动重建所有启动的AppCompatActivity,但仅在主题状态发生变化且AppCompatActivity获取到焦点的时候,所以应处理好各个组件的状态保存和恢复,尤其是Fragment。
比如ActivityA和ActivityB都继承了AppCompatActivity,ActivityA启动了ActivityB,在ActivityB中切换了暗黑模式,那么ActivityB会立即销毁重建。按返回键,当ActivityA重新获取到焦点时,也会自动销毁重建以应用ActivityB设置的暗黑模式,即setDefaultNightMode()设置的暗黑模式是对已启动的和将要启动的所有AppCompatActivity全局有效的。但对于未继承AppCompatActivity的Activity,它们虽然也支持暗黑模式,但并不会自动重建来响应其它Activity设置的暗黑模式。
因为setDefaultNightMode()设置的暗黑模式是全局有效的,且只有在暗黑模式设置发生变化时才会引起Activity的销毁重建,所以只需在Activity启动时设置一次即可,不用在每个Activity中都执行同样的设置。
避免Activity重建
使用setDefaultNightMode()设置暗黑模式会导致Activity销毁重建,在某些场景中,我们可能不想重建Activity,Android也提供了一种方式来让Activity获取到状态变化的通知,然后手动去处理颜色变化。
首先在AndroidManifest中设置该Activity不响应uiMode的变化:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="me.apqx.demo">
<activity android:name=".ThemeActivity"
android:configChanges="uiMode"/>
</manifest>
再覆写Activity的onConfigurationChanged()方法来监听暗黑模式的状态变化:
// 当在AndroidManifest中配置的该Activity的指定状态发生变化时,这里会被调用
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// 当前的暗黑模式
val currentNightModeOn = isDarkModeOn(this)
// 根据当前的暗黑模式是否启用,手动更改组件的显示颜色
if (currentNightModeOn) {
tv_title.setTextColor(Color.WHITE)
} else {
tv_title.setTextColor(Color.BLACK)
}
}
// 检查当前App是否处于暗黑模式,可以是用户自己设定进入的暗黑模式,也可以是跟随系统设置进入的暗黑模式
fun isDarkModeOn(context: Context): Boolean {
val mode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return mode == Configuration.UI_MODE_NIGHT_YES
}
这种方式避免了Activity的销毁重建可能引起的一系列生命周期问题,但是手动设置每个组件的颜色显然不如一个简单的-night资源限定符方便。