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 適配。

妥妥的。

相關文章
相關標籤/搜索