在 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 等。如果項目有適配暗黑模式的計劃,個人建議可以按以下幾步走:
開發者選項開啓「強制啓用 SmartDark」
替換有問題的資源,進行初步適配
compileSdkVersion 升級到 29
開啓 Force Dark
和設計師溝通,對部分控件單獨適配
總結
使用 DayNight 主題可以實現暗黑模式的適配,但這種方法在實際項目中可操作性不高。Android 10 新增的暗黑模式特性叫 Force Dark 強制暗黑,只需給主題添加一個允許開啓的配置即可。Force Dark 的實現方式是降低背景亮度,提高字體亮度,本質是對色值進行亮度取反。最後,在 Android 10 的設備上,可以開啓開發者選項中的「強制啓用 SmartDark」,提前用 Force Dark 適配。
妥妥的。
作者:NanBox
鏈接:https://juejin.im/post/5ecf8c9f51882542f871d7a6