1、夜间模式怎么实现?

先看一个实现夜间模式的demo。

首先看MainActivity的代码:

public class MainActivity extends Activity {
    private TextView tv;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tv = findViewById(R.id.tv);
        findViewById(R.id.model_btn).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 获取Resources
                Resources resources = getResources();
                DisplayMetrics dm = resources.getDisplayMetrics();
                // 获取Configuration
                Configuration config = resources.getConfiguration();
                // 修改Configuration的夜间模式
                if ((config.uiMode & Configuration.UI_MODE_NIGHT_YES) == Configuration.UI_MODE_NIGHT_YES) {
                    config.uiMode &= ~Configuration.UI_MODE_NIGHT_MASK;
                    config.uiMode |= Configuration.UI_MODE_NIGHT_NO;
                } else {
                    config.uiMode &= ~Configuration.UI_MODE_NIGHT_MASK;
                    config.uiMode |= Configuration.UI_MODE_NIGHT_YES;
                }
                // 重新将Configuration更新到Resources中
                getResources().updateConfiguration(config, dm);
                // 重新设置TextView的颜色,否则不生效
                tv.setTextColor(getResources().getColor(R.color.colorAccent));
            }
        });
    }
}

布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android=""
    xmlns:app=""
    xmlns:tools=""
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <TextView
        android:id="@+id/tv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:textColor="@color/colorAccent"/>
    <Button
        android:id="@+id/model_btn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        app:layout_constraintTop_toBottomOf="@id/tv"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:text="切换模式"/>
</androidx.constraintlayout.widget.ConstraintLayout>

当让我们需要在res文件夹下设置分别设置values文件夹和values-night文件夹,这样当夜间模式切换之后,通过Resource获取资源时就会根据当前模式获取不同文件夹下的资源,资源文件如下:

下面是效果图,左边是白天模式,右边是夜间模式。

上面的demo很简单,布局文件中只有一个TextView和一个Button,点击Button可以修改TextView的颜色。

2、夜间模式切换原理

有一个问题,我明明在xml文件中设置了TextView的字体颜色,但是还是要在Activity中再设置一次或者调用Activity的recreate()方法(这样会闪一下),否则夜间模式切换之后不生效。这是为什么呢?

我们通过Resources.getColor()方法追一下源码:

获取color分两步进行,第一步根据id获取一个TypeValue对象,第二步根据TypeValue和id获取ColorStateList,最后返回ColorStateList中的颜色。

那我想看下到底是怎么获取ColorStateList的?继续追:

这里有一些缓存逻辑,这些逻辑不在我们考虑范围之内,继续追红框内的代码:

从上面代码中可以看到具体的色值是通过解析颜色的xml文件得到的。此时我们并不关系xml文件是怎么解析的,我们只需要知道切换白天模式或夜间模式之后怎样找到相应的文件即可。

从上面代码可以知道文件名是存储在TypeValue.string属性中的,那么我们返回去看一下TypeValue.string属性是怎么设置的。通过上面我们知道了TypeValue对象中的属性是在ResourcesImpl.getValue()方法中设置的,所以追一下这个方法:

最终我们追到了AssetManager.getResourceValue方法,这个方法中调用了一个native方法,在这个方法中设置了TypeValue的值,但是这个native方法我们看不到。

想一下我们的demo,夜间模式切换的时候我们修改了Configuration,然后将新的Configuration重新设置到Resources中,然后获取颜色时色值就变了,所以可以猜想,获取哪个文件中的色值应该跟Configuration有关,那么我们看下Resource.updateConfigration()方法:

可以看到,最终更新后的Configuration的属性被设置到了AssetManager中了,此时又调用了native方法,我们依然看不到更深层次的原理,但是我们可以想到,AssetManager获取资源文件时一定会使用Configuration的uiMode属性,所以我们去看一下这个属性在哪用了。

看这里,惊不惊喜?意不意外?通过Configuration.resourceQualifierString方法可以获取到能被build tools解析的资源限定符。虽然没找到这个方法在哪里被调用了,但是可以猜想到,当夜间模式切换之后,AssetManager获取资源文件时会根据当前的uiMode获取到相应的资源文件。然后Xml解析器就会解析获取到的资源文件中的资源,从而找到相应的颜色。

3、为什么只在xml中设置字体颜色切换夜间模式不生效?

至此,我们了解了切换夜间模式的原理,但是为什么只在xml中设置夜间字体颜色在切换夜间模式时不生效呢?

很显然,因为修改夜间模式时并没有触发重新获取字体颜色的逻辑。但是我很好奇,在xml中设置夜间模式是怎么生效的呢?还是看源码,我们可以知道在xml中使用TextView之后最终会走到TextView的构造方法:

TextView的构造方法会在readTextAppearance()方法中获取字体颜色,这个方法需要传入一个TypeArray对象,这个对象是通过theme获取的。

上图可见,textColor是在TypeArray对象中获取的,接着往下看:

可以看到这个方法最终返回的是mResource.loadColorStateList(value, value.resourceId, mTheme)。从前面分析 夜间模式切换原理 的时候我们知道,这个方法最终是通过解析xml文件得到具体的色值,而我们关心的是解析哪个文件,所以下面看下getValueAt()方法。

通过上面代码可以发现,TypeValue.string是从Xml解析器的缓存池中获取的,也就是说为了提升性能,会提前把要解析的文件名缓存到解析器中,具体的缓存逻辑就不再往下追了。

4、最终总结

通过上面的分析我们可以知道,要实现夜间模式的切换,就需要实现下面两个步骤:

一、修改Configurations,并将修改后的Configurations对象更新到Resources中;

二、重新给视图组件设置资源,此时我们通过Resources对象获取资源时就会,Resources对象就会根据当前是否是夜间模式获取不同的资源。

在项目中重新给视图组件设置资源有两种触发方式:

第一,重新创建Activity(Android原生的深色模式就是使用这种方案),比如监听到夜间模式切换之后调用Activity的recreate()方法,但是这有个问题就是会闪一下,所有有一种方案是让Activity重新调起自己,但是这样的话原来的状态就难以保存了,尤其是当页面比较复杂时,这种方式难以接受。

第二、不重新创建Activity,而是当监听到夜间模式切换后在代码中重新给组件设置资源。这种方式优点在于可以保持当前的页面状态,缺点是需要在Java代码中给组件设置资源,如果页面复杂会增加很多工作量,而且很容易遗漏。

所以我们还应该探索一下有没有更优雅的实现方案。