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

暗黑模式

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

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

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

动态开启

在 Android 10 系统设置里增长了暗黑模式的开关,但除了系统设置,咱们也能够本身动态开启。 假如咱们项目里面有一个按钮用来开关暗黑模式,能够这样作:android

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() 方法里进行手动适配。app

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:ui

<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>
复制代码

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

是否是很简单,但直觉告诉咱们确定没有这么简单。

硬编码

咱们进入 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 适配。

妥妥的。

相关文章
相关标签/搜索