适配 Android Q 的深色模式

DarkMode 是 Google 在 Android Q 中引入的全局深色模式,与 iOS 13 一样,Android 也开始拥有系统级明暗主题。App 可选择启用或关闭深色模式,或跟随系统设置自动在两者之间切换。

在中文社区搜索 DarkMode 会发现很多人并没有理解它和Theme之间的关系,所以我一直认为阅读官方文档是学习 Android 最基本和最有效的方式。Android 团队会把新特性写在文档中希望把工作成果详细准确的传递给开发者,包括设计思路、适配方式和向前兼容方案。其中某些微妙细节会在社区的观点传递中丢失,如果只能看到质量很差的末端“教程”,知其然不知其所以然必定一头雾水。

如果清楚知道调用的 API 对组件生命周期的影响以及如何解决可能由此引起的连锁问题,一些 Bug 在某种程度上是不应该存在的,或者是可预期的,再或者当它出现时能立刻意识到问题所在。可以调侃自己每天在写 Bug,但调侃其实只应该是“调侃”。

DarkMode is not a dark theme

"深色模式不是一个暗色主题",在 Android Q 之前已经有一套主题机制,可定义配色方案构建Theme,其中包括暗色Theme,但它和深色模式有本质区别。

普通模式可配置多个Theme,深色模式更像是普通模式的镜像,是对“模式”而言的概念。普通模式下每一个Theme都应该能在深色模式找到对应的映射,它们都是Theme的集合。

Theme

深色模式出现之前需用<style>定义Theme,在AndroidManifest文件中设置ApplicationActivity默认应用的Theme,也可在Activity启动时动态设置实现更大灵活性。

首先将要使用的属性,如颜色,定义为通用attr属性:

/values/attrs.xml

<resources>
    <!-- 定义各个 Theme 都要使用的通用属性 -->
    <!-- 标题颜色 -->
    <attr name="commonTitleColor" format="color"/>
    <!-- 背景颜色 -->
    <attr name="commonBgColor" format="color"/>
</resources>

定义不同Theme下属性的值,以REDGREEN两个Theme为例:

/values/colors.xml

<resources>
    <!-- 定义 RED 和 GREEN 主题下需要用到的值 -->
    <color name="commonTitleRed">#FF0000</color>
    <color name="commonTitleGreen">#00FF00</color>
    <!-- 2 个主题都使用同样的白色背景 -->
    <color name="commonBg">#FFFFFF</color>
</resources>

定义REDGREEN主题,绑定属性对应的值:

/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中指定ApplicationActivity应用的Theme

<!-- 指定所有 Activity 的默认 Theme -->
<application
    ...
    android:theme="@style/AppTheme">
    <!-- 也可为单个 Activity 指定 Theme -->
    <activity android:name=".MainActivity"
        android:theme="@style/AppTheme.RED"/>
</application>

或者在Activity启动时动态指定:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // 取出保存的用户主题设置
    val theme = dataDao.getTheme()
    // 在 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" />

LayoutAttributes中选择要预览的主题,该属性会使用选定主题的颜色值进行预览:

android studio theme

实现动态应用Theme也很简单,只需通知Activity重建,在重建时设置Theme即可。应注意Activity重建可能导致的部分组件状态丢失和Fragment重复创建等问题,均与Activity在异常状态下的状态保存、恢复机制有关。

// 调用 Activity 实例的 recreate() 方法即可通知 Activity 重建
activity.recreate()

DarkMode

Google 在 Android Q 中引入全局深色模式,提供一个新的资源限定符-night。比如把深色模式下的颜色定义在values-night/color.xml中,切换到深色模式之后 Android 即会从这个文件中读取配置的主题颜色。

使用深色模式,主题必须继承自Theme.AppCompat.DayNightTheme.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>

REDGREEN 2 个主题在普通模式下背景是白色,深色模式下背景应该是黑色,在/values-night/color.xml文件中定义:

/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>

这样一来,深色模式同样拥有的REDGREEN 2 个主题,可灵活配置它们在深色模式下的颜色。

切换深色模式需借助AppCompat类库提供的AppCompatActivityAppCompatDelegate,可实现在深色模式的“开”、“关”和“跟随系统”三种状态间切换。需要注意,深色模式是在Android Q引入,所以“跟随系统”在低版本中无效,AppCompat类库实现的则向前支持所有Android版本。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // 取出保存的用户主题设置
    val theme = dataDao.getTheme()
    // 取出保存的用户深色模式设置
    val darkMode = dataDao.getDarkMode()
    // 在 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

比如ActivityAActivityB都继承AppCompatActivityActivityA启动ActivityB,在ActivityB中切换深色模式,其会立即销毁重建。按返回键,当ActivityA重新获取到焦点时会自动销毁重建以应用ActivityB设置的深色模式,即setDefaultNightMode()设置的深色模式是对已启动的和将要启动的所有AppCompatActivity全局生效。但对于未继承AppCompatActivityActivity,它们虽然也支持深色模式,但并不会自动重建响应其它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>

覆写ActivityonConfigurationChanged()方法监听深色模式的状态变化:

// 当在 AndroidManifest 中配置状态发生变化时,这里会被调用
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资源限定符方便。

arrow_upward