适配Android Q的暗黑模式

DarkModeGoogleAndroid Q中引入的全局暗黑模式,与iOS 13一样,Android也开始拥有系统级明暗主题。每一个App都可以选择启用或关闭暗黑模式,或者选择跟随系统设置自动在普通模式暗黑模式之间切换。

在中文社区搜索暗黑模式会发现很多人并没有理解DarkModeTheme之间的关系,所以我依旧认为阅读官方文档是学习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文件中定义ApplicationActivity要使用的Theme,也可以在Activity启动时动态设定当前Activity要使用的Theme来实现更大的灵活性。

首先将要使用的一些属性,如颜色,抽象出来,定义为通用的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启动时动态的指定该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" />

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

android studio theme

要实现根据用户的点击选择动态应用Theme也很简单,用户点击后,通知Activity重建,并在重建时设置Theme即可。应注意由Activity的重建可能导致的部分组件状态丢失和Fragment重复创建等问题,它们都和Activity在异常状态下的状态保存、恢复机制有关。

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

DarkMode

GoogleAndroid Q中引入全局的暗黑模式,上面已经提到,暗黑模式就是普通模式下主题的映射,所以Android提供了一个新的资源限定符-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>

REDGREEN2个主题在普通模式下背景是白色,在暗黑模式下,背景应该是黑色,只需要在/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>

这样一来,暗黑模式同样拥有普通模式下的REDGREEN2个主题,可以非常灵活的配置这两个主题在暗黑模式下的颜色。

切换暗黑模式也非常简单,只需要借助AppCompat类库提供的AppCompatActivityAppCompatDelegate,就可以实现在暗黑模式跟随系统这三种状态间切换。需要注意的是,因为全局的暗黑模式是在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

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

因为setDefaultNightMode()设置的暗黑模式是全局有效的,且只有在暗黑模式设置发生变化时才会引起Activity的销毁重建,所以只需在Activity启动时设置一次即可,不用在每个Activity中都执行同样的设置。

避免Activity重建

使用setDefaultNightMode()设置暗黑模式会导致Activity销毁重建,在某些场景中,我们可能不想重建ActivityAndroid也提供了一种方式来让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中配置的该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资源限定符方便。

arrow_upward