暗黑模式
在 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:
第二步,没有第二步了,现在这个项目已经支持暗黑模式了,开启暗黑模式就能看到效果:
是不是很简单,但直觉告诉我们肯定没有这么简单。
硬编码
我们进入 MainActivity 的布局文件 activity_main,可以发现这里面是完全没有使用硬编码的。
什么叫硬编码?就是我们平时所说的「写死」。要是我们写死了一个色值,暗黑模式还能生效吗?
马上试一下,我们给根布局写死一个白色背景 android:background="#FFFFFF",切换暗黑模式就变成了这样:
可以看到,在写死色值的情况下暗黑模式就失效了。下面看看对于自定义的色值,要如何适配。
value-night
在 colors.xml 里添加一个配置颜色,比如:
#FFFFFF
这个是在普通模式下使用的色值,为了适配暗黑模式,还需要一个在暗黑模式下对应的色值。
新建 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 主题了,把配置改成如下:
我们把主题换回 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 等。
如果项目有适配暗黑模式的计划,个人建议可以按以下几步走:
- 开发者选项开启「强制启用 SmartDark」
- 替换有问题的资源,进行初步适配
- compileSdkVersion 升级到 29
- 开启 Force Dark
- 和设计师沟通,对部分控件单独适配
总结
使用 DayNight 主题可以实现暗黑模式的适配,但这种方法在实际项目中可操作性不高。
Android 10 新增的暗黑模式特性叫 Force Dark 强制暗黑,只需给主题添加一个允许开启的配置即可。
Force Dark 的实现方式是降低背景亮度,提高字体亮度,本质是对色值进行亮度取反。
最后,在 Android 10 的设备上,可以开启开发者选项中的「强制启用 SmartDark」,提前用 Force Dark 适配。
妥妥的。