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

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

妥妥的。


作者:NanBox
鏈接:https://juejin.im/post/5ecf8c9f51882542f871d7a6

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章