Android深色模式適配原理分析

塗程發表於2020-10-13

作者: Bug總柴

背景

從Android10(API 29)開始,在原有的主題適配的基礎上,Google開始提供了Force Dark機制,在系統底層直接對顏色和圖片進行轉換處理,原生支援深色模式。深色模式可以節省電量、改善弱勢及強光敏感使用者的可視性,並能在環境亮度較暗的時候保護視力,更是夜間活躍使用者的強烈需求。對深色模式的適配有利於提升使用者口碑。

深色模式在安卓上可以分為以下四種場景:

  • 強制深色模式
  • 強制淺色模式
  • 跟隨系統
  • 低電量自動切換深色

以下將介紹如何設定深色模式以及如何對深色模式進行適配。

資源配置限定符

我們常見的需要設定的資源有drawable、layout、mipmap和values等,對於這些資源,我們可以用一些限定符來表示提供一些備用資源,例如drawable-xhdpi表示超密度螢幕使用的資源,或者layout-land表示橫向狀態使用的佈局。

同樣的深色模式可以使用資源的限定符-night來表示在深色模式中使用的資源。如下圖所示:


使用了-night限定符的資料夾裡面的資源我們稱為night資源,沒有使用-night限定符的資源我們稱為notnight資源。

其中drawable-night-xhdpi可以放置對應超密度螢幕使用的深色模式的圖片,values-night可以宣告對應深色模式使用的色值和主題。

所有的資源限定符定義以及新增的順序(例如-night必須在-xhdpi之前)可檢視應用資源概覽中的配置限定符名稱表。

深色模式判斷&設定


判斷當前是否深色模式

Configuration.uiMode 有三種NIGHT的模式

  • UI_MODE_NIGHT_NO 表示當前使用的是notnight模式資源
  • UI_MODE_NIGHT_YES 表示當前使用的是night模式資源
  • UI_MODE_NIGHT_UNDEFINED 表示當前沒有設定模式

可以通過以下的程式碼來判斷當前是否處於深色模式:

/**
 * 判斷當前是否深色模式
 *
 * @return 深色模式返回 true,否則返回false
 */
fun isNightMode(): Boolean {
  return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
    Configuration.UI_MODE_NIGHT_YES -> true
    else -> false
  }
}

Tips: 對於一些從網路介面服務獲取的需要對深色模式區分的色值或者圖片,可以使用上述的判斷來獲取對應的資源。

判斷當前深色模式場景

通過AppCompatDelegate.getDefaultNightMode())可以獲取五種深色模式場景:

  • MODE_NIGHT_AUTO_BATTERY 低電量模式自動開啟深色模式
  • MODE_NIGHT_FOLLOW_SYSTEM 跟隨系統開啟和關閉深色模式(預設)
  • MODE_NIGHT_NO 強制使用notnight資源,表示非深色模式
  • MODE_NIGHT_YES 強制使用night資源
  • MODE_NIGHT_UNSPECIFIED 配合 setLocalNightMode(int)) 使用,表示由Activity通過AppCompactActivity.getDelegate()來單獨設定頁面的深色模式,不設定全域性模式

模式設定

深色模式設定可以從三個層級設定,分別是系統層、Applcation層以及Activity層。底層的設定會覆蓋上層的設定,例如系統設定了深色模式,但是Application設定了淺色模式,那麼應用會顯示淺色主題。

系統層是指系統設定中,根據不同產商的手機,可以在設定->顯示中修改系統為深色模式。

Application層通過AppCompatDelegate.setDefaultNightMode()設定深色模式。

Activity層通過getDelegate().setLocalNightMode())設定深色模式。

當深色模式改變時,Activity會重建,如果不希望Activity重建,可以在AndroidManifest.xml中對對應的Activity設定android:configChanges=“uiMode”,不過設定之後頁面的顏色改變需要Activity在中通過監聽onConfigurationChanged來動態改變。

通過AppCompatDelegate.setDefaultNightMode(int))可以設定深色模式,原始碼如下:

public static void setDefaultNightMode(@NightMode int mode) {
  if (DEBUG) {
    Log.d(TAG, String.format("setDefaultNightMode. New:%d, Current:%d",
                             mode, sDefaultNightMode));
  }
  switch (mode) {
    case MODE_NIGHT_NO:
    case MODE_NIGHT_YES:
    case MODE_NIGHT_FOLLOW_SYSTEM:
    case MODE_NIGHT_AUTO_TIME:
    case MODE_NIGHT_AUTO_BATTERY:
      if (sDefaultNightMode != mode) {
        sDefaultNightMode = mode;
        applyDayNightToActiveDelegates();
      }
      break;
    default:
      Log.d(TAG, "setDefaultNightMode() called with an unknown mode");
      break;
  }
}

從原始碼可以看出設定 MODE_NIGHT_UNSPECIFIED 模式是不會生效的。

Tips:注意,深色模式變化會導致Activity重建。

適配方案


自定義適配

1. 主題

將Application和Activity的主題修改為整合自Theme.AppCompat.DayNight或者Theme.MaterialComponents.DayNight,就可以對於大部分的控制元件得到較好的深色模式支援。我們看下DayNight主題的定義:

res/values/values.xml

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:ns1="urn:oasis:names:tc:xliff:document:1.2">        
    <!-- ... -->
    <style name="Theme.AppCompat.DayNight" parent="Theme.AppCompat.Light"/>
    <style name="Theme.AppCompat.DayNight.DarkActionBar" parent="Theme.AppCompat.Light.DarkActionBar"/>
    <style name="Theme.AppCompat.DayNight.Dialog" parent="Theme.AppCompat.Light.Dialog"/>
    <style name="Theme.AppCompat.DayNight.Dialog.Alert" parent="Theme.AppCompat.Light.Dialog.Alert"/>
    <style name="Theme.AppCompat.DayNight.Dialog.MinWidth" parent="Theme.AppCompat.Light.Dialog.MinWidth"/>
    <style name="Theme.AppCompat.DayNight.DialogWhenLarge" parent="Theme.AppCompat.Light.DialogWhenLarge"/>
    <style name="Theme.AppCompat.DayNight.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar"/>
    <!-- ... -->
</resources>

res/values-night-v8/values-night-v8.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Theme.AppCompat.DayNight" parent="Theme.AppCompat"/>
    <style name="Theme.AppCompat.DayNight.DarkActionBar" parent="Theme.AppCompat"/>
    <style name="Theme.AppCompat.DayNight.Dialog" parent="Theme.AppCompat.Dialog"/>
    <style name="Theme.AppCompat.DayNight.Dialog.Alert" parent="Theme.AppCompat.Dialog.Alert"/>
    <style name="Theme.AppCompat.DayNight.Dialog.MinWidth" parent="Theme.AppCompat.Dialog.MinWidth"/>
    <style name="Theme.AppCompat.DayNight.DialogWhenLarge" parent="Theme.AppCompat.DialogWhenLarge"/>
    <style name="Theme.AppCompat.DayNight.NoActionBar" parent="Theme.AppCompat.NoActionBar"/>
    <style name="ThemeOverlay.AppCompat.DayNight" parent="ThemeOverlay.AppCompat.Dark"/>
</resources>

res/values/values.xml

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:ns1="urn:oasis:names:tc:xliff:document:1.2" xmlns:ns2="http://schemas.android.com/tools">
    <!-- ... -->
    <style name="Theme.MaterialComponents.DayNight" parent="Theme.MaterialComponents.Light"/>
    <style name="Theme.MaterialComponents.DayNight.BottomSheetDialog" parent="Theme.MaterialComponents.Light.BottomSheetDialog"/>
    <style name="Theme.MaterialComponents.DayNight.Bridge" parent="Theme.MaterialComponents.Light.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.DarkActionBar" parent="Theme.MaterialComponents.Light.DarkActionBar"/>
    <style name="Theme.MaterialComponents.DayNight.DarkActionBar.Bridge" parent="Theme.MaterialComponents.Light.DarkActionBar.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog" parent="Theme.MaterialComponents.Light.Dialog"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.Alert" parent="Theme.MaterialComponents.Light.Dialog.Alert"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.Alert.Bridge" parent="Theme.MaterialComponents.Light.Dialog.Alert.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.Bridge" parent="Theme.MaterialComponents.Light.Dialog.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.FixedSize" parent="Theme.MaterialComponents.Light.Dialog.FixedSize"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.FixedSize.Bridge" parent="Theme.MaterialComponents.Light.Dialog.FixedSize.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.MinWidth" parent="Theme.MaterialComponents.Light.Dialog.MinWidth"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.MinWidth.Bridge" parent="Theme.MaterialComponents.Light.Dialog.MinWidth.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.DialogWhenLarge" parent="Theme.MaterialComponents.Light.DialogWhenLarge"/>
    <style name="Theme.MaterialComponents.DayNight.NoActionBar" parent="Theme.MaterialComponents.Light.NoActionBar"/>
    <style name="Theme.MaterialComponents.DayNight.NoActionBar.Bridge" parent="Theme.MaterialComponents.Light.NoActionBar.Bridge"/>
    <!-- ... -->
</resources>

res/values-night-v8/values-night-v8.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Theme.MaterialComponents.DayNight" parent="Theme.MaterialComponents"/>
    <style name="Theme.MaterialComponents.DayNight.BottomSheetDialog" parent="Theme.MaterialComponents.BottomSheetDialog"/>
    <style name="Theme.MaterialComponents.DayNight.Bridge" parent="Theme.MaterialComponents.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.DarkActionBar" parent="Theme.MaterialComponents"/>
    <style name="Theme.MaterialComponents.DayNight.DarkActionBar.Bridge" parent="Theme.MaterialComponents.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog" parent="Theme.MaterialComponents.Dialog"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.Alert" parent="Theme.MaterialComponents.Dialog.Alert"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.Alert.Bridge" parent="Theme.MaterialComponents.Dialog.Alert.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.Bridge" parent="Theme.MaterialComponents.Dialog.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.FixedSize" parent="Theme.MaterialComponents.Dialog.FixedSize"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.FixedSize.Bridge" parent="Theme.MaterialComponents.Dialog.FixedSize.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.MinWidth" parent="Theme.MaterialComponents.Dialog.MinWidth"/>
    <style name="Theme.MaterialComponents.DayNight.Dialog.MinWidth.Bridge" parent="Theme.MaterialComponents.Dialog.MinWidth.Bridge"/>
    <style name="Theme.MaterialComponents.DayNight.DialogWhenLarge" parent="Theme.MaterialComponents.DialogWhenLarge"/>
    <style name="Theme.MaterialComponents.DayNight.NoActionBar" parent="Theme.MaterialComponents.NoActionBar"/>
    <style name="Theme.MaterialComponents.DayNight.NoActionBar.Bridge" parent="Theme.MaterialComponents.NoActionBar.Bridge"/>
    <style name="ThemeOverlay.MaterialComponents.DayNight.BottomSheetDialog" parent="ThemeOverlay.MaterialComponents.BottomSheetDialog"/>
    <style name="Widget.MaterialComponents.ActionBar.PrimarySurface" parent="Widget.MaterialComponents.ActionBar.Surface"/>
    <style name="Widget.MaterialComponents.AppBarLayout.PrimarySurface" parent="Widget.MaterialComponents.AppBarLayout.Surface"/>
    <style name="Widget.MaterialComponents.BottomAppBar.PrimarySurface" parent="Widget.MaterialComponents.BottomAppBar"/>
    <style name="Widget.MaterialComponents.BottomNavigationView.PrimarySurface" parent="Widget.MaterialComponents.BottomNavigationView"/>
    <style name="Widget.MaterialComponents.TabLayout.PrimarySurface" parent="Widget.MaterialComponents.TabLayout"/>
    <style name="Widget.MaterialComponents.Toolbar.PrimarySurface" parent="Widget.MaterialComponents.Toolbar.Surface"/>
</resources>

Tips: MaterialComponents.Bridge繼承自AppCompat主題,並增加了Material Components的主題屬性,如果專案之前是用的AppCompat,那麼使用對應的Bridge主題可以快速切換到Material Design。

從上面的分析可以看出,DayNight就是在values以及values-night中分別定義了淺色和深色的主題。如果我們的主題直接繼承DayNight主題,那麼就不需要重複地宣告對應的night主題資源了。

如果我們想對深色模式主題新增自定義屬性,那麼我們可以不繼承DayNight主題,並顯示地宣告主題對應的night資源,例如

res/values/themes.xml

<style name="Theme.MyApp" parent="Theme.MaterialComponents.Light">
    <!-- ... -->
    <item name="android:windowLightStatusBar">true</item>
</style>

res/values-night/themes.xml

<style name="Theme.MyApp" parent="Theme.MaterialComponents">
    <!-- ... -->
    <item name="android:windowLightStatusBar">false</item>
</style>

Tips: 若需要動態修改主題要在呼叫inflate之前呼叫,否則不會生效。

2. 色值

主題切換顏色

除了定義不同模式使用不同的主題,我們還可以對主題設定自定義的色值。在設定主題色值之前,我們先了解一下Android主題的顏色系統。

  • colorPrimary:主要品牌顏色,一般用於ActionBar背景
  • colorPrimaryDark:預設用於頂部狀態列和底部導航欄
  • colorPrimaryVariant:主要品牌顏色的可選顏色
  • colorSecondary:第二品牌顏色
  • colorSecondaryVariant:第二品牌顏色的可選顏色
  • colorPrimarySurface:對應Light主題指向colorPrimary,Dark主題指向colorSurface
  • colorOn[Primary, Secondary, Surface …],在Primary等這些背景的上面內容的顏色,例如ActioBar上面的文字顏色
  • colorAccent:預設設定給colorControlActivated,一般是主要品牌顏色的明亮版本補充
  • colorControlNormal:圖示和控制項的正常狀態顏色
  • colorControlActivated:圖示和控制項的選中顏色(例如Checked或者Switcher)
  • colorControlHighlight:點選高亮效果(ripple或者selector)
  • colorButtonNormal:按鈕預設狀態顏色
  • colorSurface:cards, sheets, menus等控制元件的背景顏色
  • colorBackground:頁面的背景顏色
  • colorError:展示錯誤的顏色
  • textColorPrimary:主要文字顏色
  • textColorSecondary:可選文字顏色

Tips: 當某個屬性同時可以通過 ?attr/xxx 或者?android:attr/xxx獲取時,最好使用?attr/xxx,因為?android:attr/xxx是通過系統獲取,而?attr/xxx是通過靜態庫類似於AppCompat 或者 Material Design Component引入的。使用非系統版本的屬性可以提高平臺通用性。

如果需要自定義主題顏色,我們可以對顏色分別定義notnight和night兩份,放在values以及values-night資原始檔夾中,並在自定義主題時,傳入給對應的顏色屬性。例如:

res/values/styles.xml

<resources>
    <style name="DayNightAppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar.Bridge">
        <item name="colorPrimary">@color/color_bg_1</item>
        <item name="colorPrimaryDark">@color/color_bg_1</item>
        <item name="colorAccent">@color/color_main_1</item>
    </style>
</resources>

res/values/colors.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="color_main_1">#4D71FF</color>
    <color name="color_bg_1">#FFFFFF</color>
    <color name="color_text_0">#101214</color>
    <color name="color_light">#E0A62E</color>
</resources>

res/values-night/colors.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="color_main_1">#FF584D</color>
    <color name="color_bg_1">#0B0C0D</color>
    <color name="color_text_0">#F5F7FA</color>
    <color name="color_light">#626469</color>
</resources>

控制元件切換顏色

同樣的,我們可以在佈局的XML檔案中直接使用定義好的顏色值,例如

<TextView 
      android:id="@+id/auto_color_text"
      android:text="自定義變色文字"
      android:background="@drawable/bg_text"
      android:textColor="@color/color_text_0" />
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <stroke android:color="@color/color_text_0" android:width="2dp"/>
    <solid android:color="@color/color_bg_1" />
</shape>

這樣這個文字就會在深色模式中展示為黑底白字,在非深色模式中展示為白底黑字。

動態設定顏色

如果需要程式碼設定顏色,如果色值已經設定過notnight和night兩份,那麼直接設定顏色就可以得到深色模式變色效果。

auto_color_text.setTextColor(ContextCompat.getColor(this, R.color.color_text_0))

如果色值是從服務介面獲取,那麼可以使用上述深色模式的判斷設定。

auto_color_text.setTextColor(if (isNightMode()) {
  Color.parseColor(darkColorFromNetwork)
} else {
  Color.parseColor(colorFromNetwork)
})

3. 圖片&動畫


普通圖片&Gif圖片

將圖片分為明亮模式和深色模式兩份,分別放置在drawable-night-xxx以及drawable-xxx資料夾中,並在view中直接使用即可,當深色模式切換時,會使用對應深色模式的資源。如下圖所示:

<ImageView android:src="@drawable/round_fingerprint" />

Vector圖片

在Vector資源定義時,通過指定畫筆顏色來實現對深色模式的適配,例如:

<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:tint="@color/color_light"
    android:viewportWidth="24"
    android:viewportHeight="24">
    <path
        android:fillColor="@android:color/white"
        android:pathData="M6.29,14.29L9,17v4c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-4l2.71,-2.71c0.19,-0.19 0.29,-0.44 0.29,-0.71L18,10c0,-0.55 -0.45,-1 -1,-1L7,9c-0.55,0 -1,0.45 -1,1v3.59c0,0.26 0.11,0.52 0.29,0.7zM12,2c0.55,0 1,0.45 1,1v1c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1L11,3c0,-0.55 0.45,-1 1,-1zM4.21,5.17c0.39,-0.39 1.02,-0.39 1.42,0l0.71,0.71c0.39,0.39 0.39,1.02 0,1.41 -0.39,0.39 -1.02,0.39 -1.41,0l-0.72,-0.71c-0.39,-0.39 -0.39,-1.02 0,-1.41zM17.67,5.88l0.71,-0.71c0.39,-0.39 1.02,-0.39 1.41,0 0.39,0.39 0.39,1.02 0,1.41l-0.71,0.71c-0.39,0.39 -1.02,0.39 -1.41,0 -0.39,-0.39 -0.39,-1.02 0,-1.41z" />
</vector>

其中android:tint為疊加顏色,@color/color_light已經分別定義好了notnight和night的色值。

Lottie

對於Lottie動畫,我們可以使用Lottie的Dynamic Properties特性來針對深色模式進行顏色變化。例如我們有以下兩個動畫,左邊是由顏色填充的機器人,右邊是由描邊生成的正在播放動畫,我們可以呼叫LottieAnimationView.resolveKeyPath()方法獲取動畫的路徑。

lottie_android_animate.addLottieOnCompositionLoadedListener {
  lottie_android_animate.resolveKeyPath(KeyPath("**")).forEach {
    Log.d(TAG, it.keysToString())
  }
  setupValueCallbacks()
}

對於機器小人列印的KeyPath如下:

2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [MasterController]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Head]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Head, Group 3]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Head, Group 3, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Blink]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Blink, Rectangle 2]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Blink, Rectangle 2, Rectangle Path 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Blink, Rectangle 2, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Blink, Rectangle 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Blink, Rectangle 1, Rectangle Path 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Blink, Rectangle 1, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Eyes]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Eyes, Group 3]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Eyes, Group 3, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [BeloOutlines]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [BeloOutlines, Group 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [BeloOutlines, Group 1, Stroke 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Shirt]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Shirt, Group 5]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Shirt, Group 5, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Body]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Body, Group 4]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [Body, Group 4, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftFoot]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftFoot, Group 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftFoot, Group 1, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [RightFoot]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [RightFoot, Group 2]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [RightFoot, Group 2, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftArmWave]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftArmWave, LeftArm]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftArmWave, LeftArm, Group 6]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftArmWave, LeftArm, Group 6, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftArmWave, LeftArm, Group 5]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [LeftArmWave, LeftArm, Group 5, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [RightArm]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [RightArm, Group 6]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [RightArm, Group 6, Fill 1]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [RightArm, Group 5]
2020-09-10 15:30:55.762 29281-29281/com.shengj.androiddarkthemedemo D/DarkThemeDemo: [RightArm, Group 5, Fill 1]

我們抽取其中的某些形狀來動態改變顏色,例如我們抽取左右手臂以及機器小人身上的T恤

private fun setupValueCallbacks() {
        // 機器人右手臂
        val rightArm = KeyPath("RightArm", "Group 6", "Fill 1")
        // 機器人左手臂
        val leftArm = KeyPath("LeftArmWave", "LeftArm", "Group 6", "Fill 1")
        // 機器人T恤
        val shirt = KeyPath("Shirt", "Group 5", "Fill 1")
        // 設定右手臂顏色
        lottie_android_animate.addValueCallback(rightArm, LottieProperty.COLOR) {
            ContextCompat.getColor(this, R.color.color_main_1)
        }
        // 設定左手臂顏色
        lottie_android_animate.addValueCallback(shirt, LottieProperty.COLOR) {
            ContextCompat.getColor(this, R.color.color_light)
        }
        // 設定T恤顏色
        lottie_android_animate.addValueCallback(leftArm, LottieProperty.COLOR) {
            ContextCompat.getColor(this, R.color.color_custom)
        }

        // 播放動畫描邊顏色
        lottie_playing_animate.addValueCallback(KeyPath("**"), LottieProperty.STROKE_COLOR) {
            ContextCompat.getColor(this, R.color.color_text_0)
        }
    }

由於color_main_1、color_light以及color_custom都已經定義過深色模式和明亮模式的色值,因此在深色模式切換時,Lottie動畫的這個機器小人的左右手臂和T恤顏色會隨著深色模式切換而變化。

同樣的對於播放動畫,我們也可以設定描邊顏色,來達到深色模式切換的效果。

網路獲取圖片

對於網路獲取的圖片,可以讓服務介面分別給出明亮模式和深色模式兩套素材,然後根據上述的深色模式判斷來進行切換

Glide.with(this)
  .load(if(isNightMode() nightImageUrl else imageUrl))
  .into(imgView)
Force Dark

看到這裡可能會有人有疑問,對於大型的專案而言,裡面已經hardcore了很多的顏色值,並且很多圖片都沒有設計成深色模式的,那做深色模式適配是不是一個不可能完成的任務呢?答案是否定的。對於大型專案而言,除了對所有的顏色和圖片定義night資源的自定義適配方法外,我們還可以對使用Light風格主題的頁面進行進行強制深色模式轉換。

我們可以分別對主題和View設定強制深色模式。對於主題,在Light主題中設定android:forceDarkAllowed,例如:

<style name="LightAppTheme" parent="Theme.MaterialComponents.Light.NoActionBar.Bridge">
    <!-- ... -->
  <item name="android:forceDarkAllowed">true</item>
</style>

對於View,設定View.setForceDarkAllowed(boolean))或者xml來設定是否支援Force Dark,預設值是true。

<View
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:forceDarkAllowed="false"/>

這裡需要注意的是,Force Dark的設定有以下幾個規則:

  1. 要強制深色模式生效必須開啟硬體加速(預設開啟)
  2. 主題設定的Force Dark僅對Light的主題有效,對非Light的主題不管是設定android:forceDarkAllowed為true或者設定View.setForceDarkAllowed(true)都是無效的。
  3. 父節點設定了不支援Force Dark,那麼子節點再設定支援Force Dark無效。例如主題設定了android:forceDarkAllowed為false,則View設定View.setForceDarkAllowed(true)無效。同樣的,如果View本身設定了支援Force Dark,但是其父layout設定了不支援,那麼該View不會執行Force Dark
  4. 子節點設定不支援Force Dark不受父節點設定支援Force Dark影響。例如View設定了支援Force Dark,但是其子Layout設定了不支援,那麼子Layout也不會執行Force Dark。

Tips:一個比較容易記的規則就是不支援Force Dark優先,View 的 Force Dark設定一般會設定成 false,用於排除某些已經適配了深色模式的 View。

下面我們從原始碼出發來理解Force Dark的這些行為,以及看看系統是怎麼實現Force Dark的。

Tips:善用 https://cs.android.com/ 原始碼搜尋網站可以方便檢視系統原始碼。

1. 主題

從主題設定的forceDarkAllowed入手查詢,可以找到

frameworks/base/core/java/android/view/ViewRootImpl.java

private void updateForceDarkMode() {
  if (mAttachInfo.mThreadedRenderer == null) return;
  // 判斷當前是否深色模式
  boolean useAutoDark = getNightMode() == Configuration.UI_MODE_NIGHT_YES;
  // 如果當前是深色模式
  if (useAutoDark) {
    // 獲取Force Dark的系統預設值
    boolean forceDarkAllowedDefault =
      SystemProperties.getBoolean(ThreadedRenderer.DEBUG_FORCE_DARK, false);
    TypedArray a = mContext.obtainStyledAttributes(R.styleable.Theme);
    // 判斷主題是否淺色主題 並且 判斷主題設定的forceDarkAllowed
    useAutoDark = a.getBoolean(R.styleable.Theme_isLightTheme, true)
      && a.getBoolean(R.styleable.Theme_forceDarkAllowed, forceDarkAllowedDefault);
    a.recycle();
  }
  // 將是否強制使用深色模式賦值給Renderer層
  if (mAttachInfo.mThreadedRenderer.setForceDark(useAutoDark)) {
    // TODO: Don't require regenerating all display lists to apply this setting
    invalidateWorld(mView);
  }
}

而這個方法正式在ViewRootImpl.enableHardwareAcceleration()方法中呼叫的,因此可以得到第一個結論:強制深色模式只在硬體加速下生效。由於userAutoDark變數會判斷當前主題是否為淺色,因此可以得到第二個結論:強制深色模式只在淺色主題下生效。直到這一步的呼叫鏈如下:

mAttachInfo.mThreadedRenderer為ThreadRenderer,繼承自HardwareRenderer,指定了接下來的渲染操作由RanderThread執行。繼續跟蹤setForceDark()方法:

frameworks/base/graphics/java/android/graphics/HardwareRenderer.java

public boolean setForceDark(boolean enable) {
  // 如果強制深色模式變化
  if (mForceDark != enable) {
    mForceDark = enable;
    // 呼叫native層設定強制深色模式邏輯
    nSetForceDark(mNativeProxy, enable);
    return true;
  }
  return false;
}

private static native void nSetForceDark(long nativeProxy, boolean enabled);

查詢nSetForceDark()方法

frameworks/base/libs/hwui/jni/android_graphics_HardwareRenderer.cpp

static const JNINativeMethod gMethods[] = {
        // ... 
    // 在Android Runtime啟動時,通過JNI動態註冊
    { "nSetForceDark", "(JZ)V", (void*)android_view_ThreadedRenderer_setForceDark },
    { "preload", "()V", (void*)android_view_ThreadedRenderer_preload },
};

查詢android_view_ThreadedRenderer_setForceDark()方法

frameworks/base/libs/hwui/jni/android_graphics_HardwareRenderer.cpp

static void android_view_ThreadedRenderer_setForceDark(JNIEnv* env, jobject clazz,
        jlong proxyPtr, jboolean enable) {
    RenderProxy* proxy = reinterpret_cast<RenderProxy*>(proxyPtr);
    // 呼叫RenderProxy的setForceDark方法
    proxy->setForceDark(enable);
}

frameworks/base/libs/hwui/renderthread/RenderProxy.cpp

void RenderProxy::setForceDark(bool enable) {
        // 呼叫CanvasContext的setForceDark方法
    mRenderThread.queue().post([this, enable]() { mContext->setForceDark(enable); });
}

frameworks/base/libs/hwui/renderthread/CanvasContext.h

// Force Dark的預設值是false
bool mUseForceDark = false;
// 設定mUseForceDark標誌
void setForceDark(bool enable) { mUseForceDark = enable; }
bool useForceDark() {
  return mUseForceDark;
}

接著查詢呼叫userForceDark()方法的地方

frameworks/base/libs/hwui/TreeInfo.cpp

TreeInfo::TreeInfo(TraversalMode mode, renderthread::CanvasContext& canvasContext)
        : mode(mode)
        , prepareTextures(mode == MODE_FULL)
        , canvasContext(canvasContext)
        // 設定disableForceDark變數
        , disableForceDark(canvasContext.useForceDark() ? 0 : 1)
        , screenSize(canvasContext.getNextFrameSize()) {}

}  // namespace android::uirenderer
frameworks/base/libs/hwui/TreeInfo.h

class TreeInfo {
public:
    // ...
    int disableForceDark;
    // ...
};

到了這裡,可以看出,當設定了Force Dark之後,最終會設定到TreeInfo類中的disableForceDark變數,如果沒有設定主題的Force Dark,那麼根據false的預設值,disableForceDark變數會別設定成1,如果設定了使用強制深色模式,那麼disableForceDark會變成0。

這個變數最終會用在RenderNode的RenderNode.handleForceDark()過程中,到達的流程如下圖:

frameworks/base/libs/hwui/RenderNode.cpp

void RenderNode::prepareTreeImpl(TreeObserver& observer, TreeInfo& info, bool functorsNeedLayer) {
    // ...
    // 同步正在處理的RenderNode Property變化
    if (info.mode == TreeInfo::MODE_FULL) {
        pushStagingPropertiesChanges(info);
    }
    // 如果當前View不允許被ForceDark,那麼info.disableForceDark值+1
    if (!mProperties.getAllowForceDark()) {
        info.disableForceDark++;
    }
    // ...
    // 同步正在處理的Render Node的Display List,實現具體深色的邏輯
    if (info.mode == TreeInfo::MODE_FULL) {
        pushStagingDisplayListChanges(observer, info);
    }

    if (mDisplayList) {
        info.out.hasFunctors |= mDisplayList->hasFunctor();
        bool isDirty = mDisplayList->prepareListAndChildren(
                observer, info, childFunctorsNeedLayer,
                [](RenderNode* child, TreeObserver& observer, TreeInfo& info,
                   bool functorsNeedLayer) {
                    // 遞迴呼叫子節點的prepareTreeImpl。
                    // 遞迴呼叫之前,若父節點不允許強制深色模式,disableForceDark已經不為0,
                    //     子節點再設定允許強制深色模式不會使得disableForceDark的值減少,
                    //     因此有第三個規則:父節點設定了不允許深色模式,子節點再設定允許深色模式無效。
                    // 同樣的,遞迴呼叫之前,若父節點允許深色模式,disableForceDark為0,
                    //     子節點再設定不允許強制深色模式,則disableForceDark值還是會++,不為0
                    //     因此有第四個規則:子節點設定不允許強制深色模式不受父節點設定允許強制深色模式影響。
                    child->prepareTreeImpl(observer, info, functorsNeedLayer);
                });
        if (isDirty) {
            damageSelf(info);
        }
    }
    pushLayerUpdate(info);
    // 遞迴結束後將之前設定過+1的值做回退-1恢復操作,避免影響其他兄弟結點的深色模式值判斷
    if (!mProperties.getAllowForceDark()) {
        info.disableForceDark--;
    }
    info.damageAccumulator->popTransform();
}

void RenderNode::pushStagingDisplayListChanges(TreeObserver& observer, TreeInfo& info) {
    // ...
    // 同步DisplayList
    syncDisplayList(observer, &info);
    // ...
}

void RenderNode::syncDisplayList(TreeObserver& observer, TreeInfo* info) {
    // ...
    if (mDisplayList) {
        WebViewSyncData syncData {
            // 設定WebViewSyncData的applyForceDark
            .applyForceDark = info && !info->disableForceDark
        };
        mDisplayList->syncContents(syncData);
        // 強制執行深色模式執行
        handleForceDark(info);
    }
}

void RenderNode::handleForceDark(android::uirenderer::TreeInfo *info) {
    if (CC_LIKELY(!info || info->disableForceDark)) {
        // 如果disableForceDark不為0,關閉強制深色模式,則直接返回
        return;
    }
    auto usage = usageHint();
    const auto& children = mDisplayList->mChildNodes;
    // 如果有文字表示是前景策略
    if (mDisplayList->hasText()) {
        usage = UsageHint::Foreground;
    }
    if (usage == UsageHint::Unknown) {
        // 如果子節點大於1或者第一個子節點不是背景,那麼設定為背景策略
        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);
        }
    }
    // 根據前景還是背景策略對顏色進行提亮或者加深
    mDisplayList->mDisplayList.applyColorTransform(
            usage == UsageHint::Background ? ColorTransform::Dark : ColorTransform::Light);
}

Tips:View的繪製會根據VSYNC訊號,將UI執行緒的Display List樹同步到Render執行緒的Display List樹,並通過生產者消費者模式將layout資訊放置到SurfaceFlinger中,並最後交給Haredware Composer進行合成繪製。具體View渲染邏輯見參考章節的15~19文章列表。

frameworks/base/libs/hwui/RecordingCanvas.cpp

void DisplayListData::applyColorTransform(ColorTransform transform) {
    // 使用transform作為引數執行color_transform_fns函式組
    this->map(color_transform_fns, transform);
}

template <typename Fn, typename... Args>
inline void DisplayListData::map(const Fn fns[], Args... args) const {
    auto end = fBytes.get() + fUsed;
    // 遍歷需要繪製的元素op,並呼叫對應型別的colorTransformForOp函式
    for (const uint8_t* ptr = fBytes.get(); ptr < end;) {
        auto op = (const Op*)ptr;
        auto type = op->type;
        auto skip = op->skip;
        if (auto fn = fns[type]) {  // We replace no-op functions with nullptrs
            fn(op, args...);        // to avoid the overhead of a pointless call.
        }
        ptr += skip;
    }
}

typedef void (*color_transform_fn)(const void*, ColorTransform);

#define X(T) colorTransformForOp<T>(),
static const color_transform_fn color_transform_fns[] = {
  // 相當於 colorTransformForOp<Flush>()
  X(Flush)
  X(Save)
  X(Restore)
  X(SaveLayer)
  X(SaveBehind)
  X(Concat44)
  X(Concat)
  X(SetMatrix)
  X(Scale)
  X(Translate)
  X(ClipPath)
  X(ClipRect)
  X(ClipRRect)
  X(ClipRegion)
  X(DrawPaint)
  X(DrawBehind)
  X(DrawPath)
  X(DrawRect)
  X(DrawRegion)
  X(DrawOval)
  X(DrawArc)
  X(DrawRRect)
  X(DrawDRRect)
  X(DrawAnnotation)
  X(DrawDrawable)
  X(DrawPicture)
  X(DrawImage)
  X(DrawImageNine)
  X(DrawImageRect)
  X(DrawImageLattice)
  X(DrawTextBlob)
  X(DrawPatch)
  X(DrawPoints)
  X(DrawVertices)
  X(DrawAtlas)
  X(DrawShadowRec)
  X(DrawVectorDrawable)
  X(DrawWebView)
};
#undef X

struct DrawImage final : Op {
    static const auto kType = Type::DrawImage;
    DrawImage(sk_sp<const SkImage>&& image, SkScalar x, SkScalar y, const SkPaint* paint,
              BitmapPalette palette)
            : image(std::move(image)), x(x), y(y), palette(palette) {
        if (paint) {
            this->paint = *paint;
        }
    }
    sk_sp<const SkImage> image;
    SkScalar x, y;
    // 這裡SK指代skia庫物件
    SkPaint paint;
    BitmapPalette palette;
    void draw(SkCanvas* c, const SkMatrix&) const { c->drawImage(image.get(), x, y, &paint); }
};

template <class T>
constexpr color_transform_fn colorTransformForOp() {
    if
        // 如果型別T有paint變數,並且有palette變數
        constexpr(has_paint<T> && has_palette<T>) {
            // It's a bitmap(繪製Bitmap)
            // 例如對於一個DrawImage的OP,最終會呼叫到這裡
            // opRaw對應DrawImage物件,transform為ColorTransform::Dark或者ColorTransform::Light
            return [](const void* opRaw, ColorTransform transform) {
                // TODO: We should be const. Or not. Or just use a different map
                // Unclear, but this is the quick fix
                const T* op = reinterpret_cast<const T*>(opRaw);
                transformPaint(transform, const_cast<SkPaint*>(&(op->paint)), op->palette);
            };
        }
    else if
        constexpr(has_paint<T>) {
            return [](const void* opRaw, ColorTransform transform) {
                // TODO: We should be const. Or not. Or just use a different map
                // Unclear, but this is the quick fix
                // 非Bitmap繪製
                const T* op = reinterpret_cast<const T*>(opRaw);
                transformPaint(transform, const_cast<SkPaint*>(&(op->paint)));
            };
        }
    else {
        return nullptr;
    }
}

frameworks/base/libs/hwui/CanvasTransform.cpp

這裡進行具體的顏色轉換邏輯,我們首先關注非Bitmap繪製的顏色轉換

// 非Bitmap繪製顏色模式轉換
bool transformPaint(ColorTransform transform, SkPaint* paint) {
    applyColorTransform(transform, *paint);
    return true;
}

// 非Bitmap繪製顏色模式轉換
static void applyColorTransform(ColorTransform transform, SkPaint& paint) {
    if (transform == ColorTransform::None) return;
    // 具體繪製顏色轉換邏輯
    SkColor newColor = transformColor(transform, paint.getColor());
    // 將畫筆顏色修改為轉換後的顏色
    paint.setColor(newColor);

    // 有漸變色情況
    if (paint.getShader()) {
        SkShader::GradientInfo info;
        std::array<SkColor, 10> _colorStorage;
        std::array<SkScalar, _colorStorage.size()> _offsetStorage;
        info.fColorCount = _colorStorage.size();
        info.fColors = _colorStorage.data();
        info.fColorOffsets = _offsetStorage.data();
        SkShader::GradientType type = paint.getShader()->asAGradient(&info);

        if (info.fColorCount <= 10) {
            switch (type) {
                // 線性漸變並且漸變顏色少於等於10個的情況
                case SkShader::kLinear_GradientType:
                    for (int i = 0; i < info.fColorCount; i++) {
                        // 對漸變色顏色進行轉換
                        info.fColors[i] = transformColor(transform, info.fColors[i]);
                    }
                    paint.setShader(SkGradientShader::MakeLinear(info.fPoint, info.fColors,
                                                                 info.fColorOffsets, info.fColorCount,
                                                                 info.fTileMode, info.fGradientFlags, nullptr));
                    break;
                default:break;
            }

        }
    }

    // 處理colorFilter
    if (paint.getColorFilter()) {
        SkBlendMode mode;
        SkColor color;
        // TODO: LRU this or something to avoid spamming new color mode filters
        if (paint.getColorFilter()->asAColorMode(&color, &mode)) {
            // 對colorFilter顏色進行轉換
            color = transformColor(transform, color);
            paint.setColorFilter(SkColorFilters::Blend(color, mode));
        }
    }
}

static SkColor transformColor(ColorTransform transform, SkColor color) {
    switch (transform) {
        case ColorTransform::Light:
            return makeLight(color);
        case ColorTransform::Dark:
            return makeDark(color);
        default:
            return color;
    }
}

// 前景色變亮
static SkColor makeLight(SkColor color) {
    // 將sRGB色彩模式轉換成Lab色彩模式
    Lab lab = sRGBToLab(color);
    // 對亮度L維度取反
    float invertedL = std::min(110 - lab.L, 100.0f);
    if (invertedL > lab.L) {
        // 若取反後亮度變亮,則替換原來亮度
        lab.L = invertedL;
        // 重新轉換為sRGB模式
        return LabToSRGB(lab, SkColorGetA(color));
    } else {
        return color;
    }
}

// 後景色變暗
static SkColor makeDark(SkColor color) {
    // 將sRGB色彩模式轉換成Lab色彩模式
    Lab lab = sRGBToLab(color);
    // 對亮度L維度取反
    float invertedL = std::min(110 - lab.L, 100.0f);
    if (invertedL < lab.L) {
        // 若取反後亮度變暗,則替換原來亮度
        lab.L = invertedL;
        // 重新轉換為sRGB模式
        return LabToSRGB(lab, SkColorGetA(color));
    } else {
        return color;
    }
}

從程式碼中可以看出,深色模式應用之後,通過對sRGB色彩空間轉換Lab色彩空間,並對錶示亮度的維度L進行取反,並判斷取反後前景色是不是更亮,後景色是不是更暗,若是的話就替換為原來的L,並再重新轉換為sRGB色彩空間,從而實現反色的效果。

我們再來看對圖片的強制深色模式處理:

// Bitmap繪製顏色模式轉換
bool transformPaint(ColorTransform transform, SkPaint* paint, BitmapPalette palette) {
    // 考慮加上filter之後圖片的明暗
    palette = filterPalette(paint, palette);
    bool shouldInvert = false;
    if (palette == BitmapPalette::Light && transform == ColorTransform::Dark) {
        // 圖片比較亮但是需要變暗
        shouldInvert = true;
    }
    if (palette == BitmapPalette::Dark && transform == ColorTransform::Light) {
        // 圖片比較暗但是需要變亮
        shouldInvert = true;
    }
    if (shouldInvert) {
        SkHighContrastConfig config;
        // 設定skia反轉亮度的filter
        config.fInvertStyle = SkHighContrastConfig::InvertStyle::kInvertLightness;
        paint->setColorFilter(SkHighContrastFilter::Make(config)->makeComposed(paint->refColorFilter()));
    }
    return shouldInvert;
}

// 獲取paint filter的palette值,若沒有filter直接返回原來的palette
static BitmapPalette filterPalette(const SkPaint* paint, BitmapPalette palette) {
    // 如果沒有filter color返回原來的palette
    if (palette == BitmapPalette::Unknown || !paint || !paint->getColorFilter()) {
        return palette;
    }

    SkColor color = palette == BitmapPalette::Light ? SK_ColorWHITE : SK_ColorBLACK;
    // 獲取filter color,並根據palette的明暗再疊加一層白色或者黑色
    color = paint->getColorFilter()->filterColor(color);
    // 根據將顏色轉換為HSV空間,並返回是圖片的亮度是亮還是暗
    return paletteForColorHSV(color);
}

從程式碼中可以看出,對於Bitmap型別的繪製,先判斷原來繪製Bitmap的明暗度,如果原來繪製的影像較為明亮但是需要變暗,或者原來繪製的影像較為暗需要變明亮,則設定一個明亮度轉換的filter到畫筆paint中。

至此,對於主題級別的強制深色轉換原理已經非常清晰。總結一下,就是需要對前景色變亮和背景色變暗,然後對於非Bitmap型別明暗變化採用的是將色值轉換為Lab顏色空間進行明亮度轉換,對於Bitmap型別的明暗變化採取設定亮度轉換的filter進行。

2. View

無論是設定View的xml的android:forceDarkAllowed屬性,還是呼叫View.setForceDarkAllowed()最後還是呼叫到frameworks/base/core/java/android/view/View.java的mRenderNode.setForceDarkAllowed()方法。

frameworks/base/graphics/java/android/graphics/RenderNode.java

public boolean setForceDarkAllowed(boolean allow) {
  return nSetAllowForceDark(mNativeRenderNode, allow);
}
nSetAllowForceDark通過JNI呼叫到android_view_RenderNode_setAllowForceDarkNavtive方法中。

frameworks/base/libs/hwui/jni/android_graphics_RenderNode.cpp

static const JNINativeMethod gMethods[] = {
  // ...
  { "nSetAllowForceDark",        "(JZ)Z", (void*) android_view_RenderNode_setAllowForceDark },
  // ...
};

static jboolean android_view_RenderNode_setAllowForceDark(CRITICAL_JNI_PARAMS_COMMA jlong renderNodePtr, jboolean allow) {
    return SET_AND_DIRTY(setAllowForceDark, allow, RenderNode::GENERIC);
}

#define SET_AND_DIRTY(prop, val, dirtyFlag) \
    (reinterpret_cast<RenderNode*>(renderNodePtr)->mutateStagingProperties().prop(val) \
        ? (reinterpret_cast<RenderNode*>(renderNodePtr)->setPropertyFieldsDirty(dirtyFlag), true) \
        : false)

最後這個是否允許深色模式的allow變數被設定到RenderProperties.h 中

frameworks/base/libs/hwui/RenderProperties.h

/*
 * Data structure that holds the properties for a RenderNode
 */
class ANDROID_API RenderProperties {
public:
    // ...
    // 設定View是否允許強制深色模式
    bool setAllowForceDark(bool allow) {
        return RP_SET(mPrimitiveFields.mAllowForceDark, allow);
    }
    // 獲取View是否允許強制深色模式
    bool getAllowForceDark() const {
        return mPrimitiveFields.mAllowForceDark;
    }
    // ...
private:
    // Rendering properties
    struct PrimitiveFields {
        // ...
        // 預設值為true
        bool mAllowForceDark = true;
        // ...
    } mPrimitiveFields;

我們回頭看下上面分析過的RenderNode.cpp的prepareTreeImpl流程

frameworks/base/libs/hwui/RenderNode.cpp

// 經過了簡化處理的prepareTreeImpl邏輯
void RenderNode::prepareTreeImpl(TreeObserver& observer, TreeInfo& info) {
    // 如果當前View不允許被ForceDark,那麼info.disableForceDark值+1
    if (!mProperties.getAllowForceDark()) {
        info.disableForceDark++;
    }

    // 同步正在處理的Render Node的Display List,實現具體深色的邏輯
    pushStagingDisplayListChanges(observer, info);

    mDisplayList->prepareListAndChildren([](RenderNode* child, TreeObserver& observer, TreeInfo& info) {
        // 遞迴呼叫子節點的prepareTreeImpl。
        // 遞迴呼叫之前,若父節點不允許強制深色模式,disableForceDark已經不為0,
        //     子節點再設定允許強制深色模式不會使得disableForceDark的值減少,
        //     因此有第三個規則:父節點設定了不允許深色模式,子節點再設定允許深色模式無效。
        // 同樣的,遞迴呼叫之前,若父節點允許深色模式,disableForceDark為0,
        //     子節點再設定不允許強制深色模式,則disableForceDark值還是會++,不為0
        //     因此有第四個規則:子節點設定不允許強制深色模式不受父節點設定允許強制深色模式影響。
        child->prepareTreeImpl(observer, info);
      });

    // 遞迴結束後將之前設定過+1的值做回退-1恢復操作,避免影響其他兄弟結點的深色模式值判斷
    if (!mProperties.getAllowForceDark()) {
        info.disableForceDark--;
    }
}

可以看出,設定View的forceDarkAllowed最終會設定到當前RenderNode的mProperties.allowForceDark屬性中,並在RenderNode遍歷的過程中影響深色模式的執行。

我們可以以下面的虛擬碼來更直觀地瞭解深色模式執行的流程:

// 深色模式渲染虛擬碼
int disableDark = if (themeAllowDark) 0 else 1;

void RenderNode(Node node) {
  if (!node.allowDark) {
    disableDark++;
  }
  if (disableDark == 0) forceDarkCurrentNode();
  for (child : node.children) {
    RenderNode(child)
  }
  if (!node.allowDark) {
    disableDark--;
  }
}

至此,我們分析完所有強制深色模式的原理。總結一下,主題預設不會強制深色,若主題設定了強制深色,則遍歷View樹對其節點進行強制深色轉換。碰到某個View不希望被強制深色,則包括它和它的所有子節點都不會被強制深色。

總結


到這裡,我們瞭解了可以通過設定-night資源以及判斷當前顏色模式來自定義切換主題、色值、圖片和動畫的顏色,也從原始碼角度瞭解Force Dark的原理和生效規則。

Demo


上述提到的程式碼可以到這個Github專案https://github.com/shenguojun/AndroidDarkThemeDemo下載

參考

  1. Google Developers - Dark Theme
  2. Material Design - Dark Theme
  3. Material Design - The color system
  4. Android 10 暗黑模式適配,你需要知道的一切
  5. Android 10 Dark Theme: Getting Started
  6. Android styling: themes vs styles
  7. Android styling: common theme attributes
  8. Android Styling: prefer theme attributes
  9. Lottie - Dynamic Properties Lottie on
  10. Android: Part 3 — Dynamic properties
  11. MIUI 深色模式適配說明
  12. OPPO 暗色模式適配說明
  13. Android Q深色模式原始碼解析
  14. Moving to the Dark Side: Dark Theme Recap
  15. Android應用程式UI硬體加速渲染環境初始化過程分析
  16. Android應用程式UI硬體加速渲染的Display List構建過程分析
  17. Android應用程式UI硬體加速渲染的Display List渲染過程分析
  18. Drawn out: how Android renders (Google I/O ‘18)
  19. 深入理解Android的渲染機制
  20. SKIA api
  21. Android Code Search

粉絲技術交流群

相關文章