Android 10 暗黑模式适配,你需要知道的一切

6,896 阅读12分钟

暗黑模式

在 Android 10 里,Dark theme 暗黑模式得到了系统级的支持。 暗黑模式不仅酷炫,而且有降低屏幕耗电、在光线较暗的环境中使用更舒适等好处。 今天带大家看一下如何适配暗黑模式,本文会从以下几点进行介绍:

  • 动态开启暗黑模式
  • 使用 DayNight 适配暗黑模式
  • 使用 Force Dark 适配暗黑模式
  • Force Dark 系统源码解析
  • 适配流程建议

相信本文会让你对暗黑模式有一个更全面的了解。

动态开启

在 Android 10 系统设置里增加了暗黑模式的开关,但除了系统设置,我们也可以自己动态开启。 假如我们项目里面有一个按钮用来开关暗黑模式,可以这样做:

btn.setOnClickListener {
    if (AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES) {
        // 关闭暗黑模式
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
    } else {
        // 开启暗黑模式
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
    }
}

如果当前开启了暗黑模式就关掉,反之开启。 你可能还看过另一种 delegate.localNightMode 的写法,同样也是可以生效的,它们的区别在于作用范围不同:

// 作用于当前项目的所有组件
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) 
// 只作用于当前组件
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES              

另外需要注意的是,在默认情况下,设置暗黑模式会重走 Activity 生命周期,需要重新渲染整个页面,所以不要在 onCreate 里直接设置。 如果不想重走生命周期,可以给 Activity 配置 android:configChanges="uiMode",但这样一来就需要在 onConfigurationChanged() 方法里进行手动适配。

NightMode

上面用到了 YES 和 NO 两种暗黑的状态,但其实还不止这两种,暗黑模式一共有这几种状态:

  • MODE_NIGHT_FOLLOW_SYSTEM 跟随系统设置
  • MODE_NIGHT_NO 关闭暗黑模式
  • MODE_NIGHT_YES 开启暗黑模式
  • MODE_NIGHT_AUTO_BATTERY 系统进入省电模式时,开启暗黑模式
  • MODE_NIGHT_UNSPECIFIED 未指定,默认值

由于很多定制系统对省电模式进行了魔改,所以使用 MODE_NIGHT_AUTO_BATTERY 不一定会生效。 另外,当 DefaultNightMode 和 LocalNightMode 都是默认值 MODE_NIGHT_UNSPECIFIED 的时候,会作 MODE_NIGHT_FOLLOW_SYSTEM 跟随系统处理。

DayNight

下面要开始对暗黑模式进行适配啦。我们使用 Android Studio 的 Basic Activity 模板创建一个项目,对它进行暗黑模式适配的改造。

DayNight 主题适配

第一步,找到当前项目使用的主题,将默认使用的 Theme.AppCompat.Light 主题修改为 Theme.AppCompat.DayNight:

<style name="AppTheme" parent="Theme.AppCompat.DayNight">
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
</style>

第二步,没有第二步了,现在这个项目已经支持暗黑模式了,开启暗黑模式就能看到效果:

是不是很简单,但直觉告诉我们肯定没有这么简单。

硬编码

我们进入 MainActivity 的布局文件 activity_main,可以发现这里面是完全没有使用硬编码的。 什么叫硬编码?就是我们平时所说的「写死」。要是我们写死了一个色值,暗黑模式还能生效吗? 马上试一下,我们给根布局写死一个白色背景 android:background="#FFFFFF",切换暗黑模式就变成了这样:

可以看到,在写死色值的情况下暗黑模式就失效了。下面看看对于自定义的色值,要如何适配。

value-night

在 colors.xml 里添加一个配置颜色,比如:

<color name="color_bg">#FFFFFF</color>

这个是在普通模式下使用的色值,为了适配暗黑模式,还需要一个在暗黑模式下对应的色值。 新建 values-night 目录,并把对应色值配置到这个目录下的 colors.xml 文件。

将根布局的背景颜色修改为 color_bg,这样就能使用我们自己想要的颜色进行适配了:

在暗黑模式下,系统会优先从 night 后缀的目录下找到对应的资源配置。 以上就是使用 DayNight 主题进行暗黑模式适配的全部内容了。

DayNight 弊端

一些关于 Android 10 暗黑模式适配的文章到这里就结束了,但其实 DayNight 主题并不是 Android 10 新增的东西,它早在 Android 6.0 就已经出现。虽然它涉及的内容不多,但大家可能也发现了,在实际项目中它的可操作性并不高。 首先,使用这种适配方式,要求我们整个项目所有的色值都不能使用硬编码,要做到这一点已经很不容易了,很多项目连统一的设计规范都很难做到。再退一步讲,就算我们所有色值都是使用 xml 配置的,但 colors.xml 里配置了成百上千个色值,我们需要对所有这些色值配置一个对应的暗黑色值,并且要确保它们在暗黑模式下能比较美观的展示。 所以,除非项目本身已经有一套严格的设计规范并且严格执行了,否则使用 DayNight 主题适配暗黑模式基本是不具有可操作性的。 Android 10 新增的当然不只是一个暗黑模式的开关而已,下面我们看一下 Android 10 有什么新特性供我们适配。

Force Dark

其实我们的需求很明确,就是使用了硬编码也能被适配成暗黑模式。Android 10 新增的 Force Dark 强制暗黑就实现了这个功能。

forceDarkAllowed

还是回到刚才的项目,把背景写死白色,再次来到 styles.xml 的主题配置。这次我们不用 DayNight 主题了,把配置改成如下:

<style name="AppTheme" parent="Theme.AppCompat.Light">
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
    <item name="android:forceDarkAllowed">true</item>
</style>

我们把主题换回 Light 亮色主题,至于为什么要用 Light 后面源码部分还会再讲到 另外,重点来了,这里还增加了一个 forceDarkAllowed 的配置,这是 compileSdkVersion 升级到 29 新增的配置,按字面意思就是「开启强制暗黑」。 这样就已经完成配置了,在 Android 10 的机器上运行一下,切换暗黑模式,记住这次的背景是写死白色的:

背景被强制转换成黑色了,细心的还会发现,右下角按钮的背景颜色也变深了。 Force Dark 这么暴力,连我们写死的色值都改了,虽然方便,但这也给我们一种不安全感。 要是 Force Dark 适配出来的颜色不是我们想要的怎么办?我们还能自定义暗黑色值吗?也是可以的。

Force Dark 自定义适配

除了主题新增了 forceDarkAllowed 这个配置,View 里面也有。 如果某个 View 的需要使用自定义色值适配暗黑模式,我们需要对这个 View 添加这个配置,让 Force Dark 排除它:

android:forceDarkAllowed="false"

然后在代码里根据当前是否处于暗黑模式,对色值进行动态设置。 对于 View 的 forceDarkAllowed,有几点需要注意:

  • 在 View 中使用这个配置的前提是,当前主题开启了 Force Dark
  • 默认值是 true,所以设为 true 和不设是一样的
  • 作用范围是当前 View 以及它所有的子 View

综上可以看出,其实目前并没有很好的 Force Dark 自定义方案。好在 Force Dark 的整体效果没什么大问题,就算要自定义,我们也尽量只对子 View 进行自定义。

Force Dark 源码解析

下面我们看一下源码,看看系统在暗黑模式下是如何对颜色进行转换的。 这里仅展示几个关键源码片段,它们之间是如何调用的就不赘述啦。

updateForceDarkMode

看源码首先我们要找到入口,入口就是主题的 forceDarkAllowed 配置,搜索一下可以发现这个配置会在 ViewRootImpl 被用到。 相关的说明已经用注释写在代码里了。

// android.view.ViewRootImpl.java

private void updateForceDarkMode() {
    if (mAttachInfo.mThreadedRenderer == null) return;

    // 判断当前是否处于暗黑模式
    boolean useAutoDark = getNightMode() == Configuration.UI_MODE_NIGHT_YES;

    if (useAutoDark) {
        // 这个是被用来作为默认值用的,这里先不管它,我们后面还会讲到。
        boolean forceDarkAllowedDefault = SystemProperties.getBoolean(ThreadedRenderer.DEBUG_FORCE_DARK, false);
        TypedArray a = mContext.obtainStyledAttributes(R.styleable.Theme);
        // 判断当前是否为 Light 主题,这也是为什么我们前面要使用 Light 主题。这也很好理解,只有当前主题是亮色的时候,才需要进行暗黑的处理。
        // 判断当前是否允许开启强制暗黑,我们就是靠它找到这个地方的。
        useAutoDark = a.getBoolean(R.styleable.Theme_isLightTheme, true)
                && a.getBoolean(R.styleable.Theme_forceDarkAllowed, forceDarkAllowedDefault);
        a.recycle();
    }

    if (mAttachInfo.mThreadedRenderer.setForceDark(useAutoDark)) {
        // TODO: Don't require regenerating all display lists to apply this setting
        invalidateWorld(mView);
    }
}

总结一下,根据这个方法我们可以知道,Force Dark 生效有三个条件:

  • 处于暗黑模式
  • 使用了 Light 亮色主题
  • 允许使用 Force Dark

源码再跟下去,发现调用了 Native 代码。

handleForceDark

下一个关键代码是 RenderNode 的 handleForceDark 函数。RenderNode 是绘制节点,一个 View 可以有多个绘制节点,比如一个 TextView 的文字部分是一个绘制节点,它设置的背景也是一个绘制节点。看一下这个函数做了什么。

// frameworks/base/libs/hwui/RenderNode.cpp

void RenderNode::handleForceDark(android::uirenderer::TreeInfo *info) {
    if (CC_LIKELY(!info || info->disableForceDark)) {
        return;
    }
    // 这个函数看似有点复杂,但其实我们只需要关注 usage 这个参数。
    // usage 有两个取值,Foreground 前景和 Background 背景。
    auto usage = usageHint();
    const auto& children = mDisplayList->mChildNodes;
    if (mDisplayList->hasText()) {
        // 如果当前节点 hasText() 含有文字,那它就是一个 Foreground 前景
        usage = UsageHint::Foreground;
    }
    // 下面的判断都是设为 Background 背景
    if (usage == UsageHint::Unknown) {
        if (children.size() > 1) {
            usage = UsageHint::Background;
        } else if (children.size() == 1 &&
                children.front().getRenderNode()->usageHint() !=
                        UsageHint::Background) {
            usage = UsageHint::Background;
        }
    }
    if (children.size() > 1) {
        // Crude overlap check
        SkRect drawn = SkRect::MakeEmpty();
        for (auto iter = children.rbegin(); iter != children.rend(); ++iter) {
            const auto& child = iter->getRenderNode();
            // We use stagingProperties here because we haven't yet sync'd the children
            SkRect bounds = SkRect::MakeXYWH(child->stagingProperties().getX(), child->stagingProperties().getY(),
                    child->stagingProperties().getWidth(), child->stagingProperties().getHeight());
            if (bounds.contains(drawn)) {
                // This contains everything drawn after it, so make it a background
                child->setUsageHint(UsageHint::Background);
            }
            drawn.join(bounds);
        }
    }
    // 根据分类,如果是背景会被设为 Dark 深色,否则是 Light 亮色。
    mDisplayList->mDisplayList.applyColorTransform(
            usage == UsageHint::Background ? ColorTransform::Dark : ColorTransform::Light);
}

这个函数做的就是对当前绘制节点进行 Foreground 还是 Background 的分类。 为了保证文字的可视度,需要保证一定的对比度,在背景切换成深色的情况下,需要把文字部分切换成亮色。

transformColor

根据分好的颜色类型,会进入 CanvasTransform 对颜色进行转换处理。这里也是 Force Dark 最核心的地方了。

// frameworks/base/libs/hwui/CanvasTransform.cpp

static SkColor transformColor(ColorTransform transform, SkColor color) {
    switch (transform) {
        case ColorTransform::Light:
            // 转换为亮色
            return makeLight(color);
        case ColorTransform::Dark:
            // 转换为暗色
            return makeDark(color);
        default:
            return color;
    }
}

根据类型调用了对应的函数转换颜色,我们看一下 makeDark 吧。

static SkColor makeDark(SkColor color) {
    Lab lab = sRGBToLab(color);
    float invertedL = std::min(110 - lab.L, 100.0f);
    if (invertedL < lab.L) {
        lab.L = invertedL;
        return LabToSRGB(lab, SkColorGetA(color));
    } else {
        return color;
    }
}

这里把 RGB 色值转换成了 Lab 的格式。 Lab 格式含有 L、a、b 三个参数,ab 对应色彩学上的两个维度,不用管它,我们要关注的是里面的 L。 L 就是亮度,它的取值范围是 0 - 100,数值越小颜色就越暗,反之就越亮。这篇文章封面的安卓机器人右边颜色就是降低亮度后的效果。 回到代码来,这里用 110 减去当前亮度,可以说是对亮度做了取反。至于为什么是用 110 而不是用 100,我猜测是为了避免使用纯黑色。 在官方暗黑模式设计规范可以看到,建议使用深灰色作为背景,而不是用纯黑色。

最后比对取反的色值和原色值的亮度,将较暗的那个色值返回。 makeLight 函数也是类似的。

static SkColor makeLight(SkColor color) {
    Lab lab = sRGBToLab(color);
    float invertedL = std::min(110 - lab.L, 100.0f);
    if (invertedL > lab.L) {
        lab.L = invertedL;
        return LabToSRGB(lab, SkColorGetA(color));
    } else {
        return color;
    }
}

所以到这里我们发现,其实 Force Dark 强制暗黑转换颜色的规则,或者说是它的本质,就是亮度取反

适配流程建议

如果你的项目 compileSdkVersion 已经升级到 29,那现在就可以开启 Force Dark 适配暗黑模式了。但很多项目要升级到 29 还有一段路要走,我们有没有办法提前适配呢?

Debug Force Dark

回到我们开始看源码的地方:

boolean forceDarkAllowedDefault = SystemProperties.getBoolean(ThreadedRenderer.DEBUG_FORCE_DARK, false);
TypedArray a = mContext.obtainStyledAttributes(R.styleable.Theme);
useAutoDark = a.getBoolean(R.styleable.Theme_isLightTheme, true)
        && a.getBoolean(R.styleable.Theme_forceDarkAllowed, forceDarkAllowedDefault);

当取不到 Theme_forceDarkAllowed 的时候,会取 DEBUG_FORCE_DARK 作为默认值,在哪里可以开启这个 DEBUG_FORCE_DARK 呢? 在 Android 10 的开发者选项里面,可以发现多了一个这样的选项:

这里的「强制启用 SmartDark 功能」就是 DEBUG_FORCE_DARK 的开关,虽然我们看了源码都知道它也没有多智能。 开启后会对所有项目生效,这样就可以提前用 Force Dark 进行适配了。

适配流程

开启 Force Dark 后大概率会发现一些有问题的图片资源,比如带有固定背景的 icon 等。 如果项目有适配暗黑模式的计划,个人建议可以按以下几步走:

  1. 开发者选项开启「强制启用 SmartDark」
  2. 替换有问题的资源,进行初步适配
  3. compileSdkVersion 升级到 29
  4. 开启 Force Dark
  5. 和设计师沟通,对部分控件单独适配

总结

使用 DayNight 主题可以实现暗黑模式的适配,但这种方法在实际项目中可操作性不高。 Android 10 新增的暗黑模式特性叫 Force Dark 强制暗黑,只需给主题添加一个允许开启的配置即可。 Force Dark 的实现方式是降低背景亮度,提高字体亮度,本质是对色值进行亮度取反。 最后,在 Android 10 的设备上,可以开启开发者选项中的「强制启用 SmartDark」,提前用 Force Dark 适配。

妥妥的。