适配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
和GREEN
2个主题在普通模式下背景是白色,在暗黑模式
下,背景应该是黑色,只需要在/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
和GREEN
2个主题,可以非常灵活的配置这两个主题在暗黑模式
下的颜色。
切换暗黑模式
也非常简单,只需要借助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
资源限定符方便。