1. 程式人生 > >Android中夜間模式的三種實現方式

Android中夜間模式的三種實現方式

參考:https://www.jianshu.com/p/f3aaed57fa15

在本篇文章中給出了三種實現日間/夜間模式切換的方案:

  1. 使用 setTheme 的方法讓 Activity 重新設定主題;
  2. 設定 Android Support Library 中的 UiMode 來支援日間/夜間模式的切換;
  3. 通過資源 id 對映,回撥自定義 ThemeChangeListener 介面來處理日間/夜間模式的切換。

 一、使用 setTheme 方法

我們先來看看使用 setTheme 方法來實現日間/夜間模式切換的方案。這種方案的思路很簡單,就是在使用者選擇夜間模式時,Activity 設定成夜間模式的主題,之後再讓 Activity 呼叫 recreate() 方法重新建立一遍就行了。 

第一步:在 colors.xml 中定義兩組顏色,分別表示日間和夜間的主題色。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 日間主題色 -->
    <color name="colorPrimary">#008577</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="colorAccent">#D81B60</color>
    <!-- 夜間主題色 -->
    <color name="nightColorPrimary">#3b3b3b</color>
    <color name="nightColorPrimaryDark">#383838</color>
    <color name="nightColorAccent">#a72b55</color>
</resources>

第二步:在 styles.xml 中定義兩組主題,也就是日間主題和夜間主題。

<resources>

    <!-- Base application theme. 日間主題 -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="android:textColor">@android:color/black</item>
        <item name="mainBackground">@android:color/white</item>
    </style>

    <!-- 夜間主題 -->
    <style name="NightAppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/nightColorPrimary</item>
        <item name="colorPrimaryDark">@color/nightColorPrimaryDark</item>
        <item name="colorAccent">@color/nightColorAccent</item>
        <item name="android:textColor">@android:color/white</item>
        <item name="mainBackground">@color/nightColorPrimaryDark</item>
    </style>

</resources>

第三步:在主題中的 mainBackground 屬性是我們自定義的屬性,用來表示背景色。所以我們需要在values裡建立一個attrs.xml。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="mainBackground" format="color|reference"></attr>
</resources>

第四步:在佈局中呼叫mainBackground屬性。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="?attr/mainBackground"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_theme"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="切換日/夜間模式" />

</LinearLayout>

<LinearLayout>android:background 屬性中,我們使用 "?attr/mainBackground" 來表示,這樣就代表著 LinearLayout 的背景色會去引用在主題中事先定義好的 mainBackground 屬性的值。這樣就實現了日間/夜間模式切換的換色了。

第五步:在Activity中的配置。

public class MainActivity extends AppCompatActivity {

    // 預設是日間模式
    private int theme = R.style.AppTheme;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 判斷是否有主題儲存
        if(savedInstanceState != null){
            theme = savedInstanceState.getInt("theme");
            setTheme(theme);
        }
        setContentView(R.layout.activity_main);
        Button btn_theme = (Button) findViewById(R.id.btn_theme);
        btn_theme.setOnClickListener(new View.OnClickListener() {

            @Override public void onClick(View v) {
                theme = (theme == R.style.AppTheme) ? R.style.NightAppTheme : R.style.AppTheme;
                MainActivity.this.recreate();
            }
        });
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putInt("theme", theme);
    }

    @Override protected void onRestoreInstanceState(Bundle savedInstanceState) {
        super.onRestoreInstanceState(savedInstanceState);
        theme = savedInstanceState.getInt("theme");
    }

}

在 Activity 中有幾點要注意一下:

  1. 呼叫 recreate() 方法後 Activity 的生命週期會呼叫 onSaveInstanceState(Bundle outState) 來備份相關的資料,之後也會呼叫 onRestoreInstanceState(Bundle savedInstanceState) 來還原相關的資料,因此我們把 theme 的值儲存進去,以便 Activity 重新建立後使用。

  2. 我們在 onCreate(Bundle savedInstanceState) 方法中還原得到了 theme 值後,setTheme() 方法一定要在 setContentView() 方法之前呼叫,否則的話就看不到效果了。

  3. recreate() 方法是在 API 11 中新增進來的,所以在 Android 2.X 中使用會拋異常。

 二、使用 Android Support Library 中的 UiMode 方法

使用 UiMode 的方法也很簡單,我們需要把 colors.xml 定義為日間/夜間兩種。之後根據不同的模式會去選擇不同的 colors.xml 。在 Activity 呼叫 recreate() 之後,就實現了切換日/夜間模式的功能。

第一步:在values/colors.xml配置。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#008577</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="colorAccent">#D81B60</color>
    <color name="textColor">#FF000000</color>
    <color name="backgroundColor">#FFFFFF</color>
</resources>

第二步:除了 values/colors.xml 之外,我們還要建立一個 values-night/colors.xml 檔案,用來設定夜間模式的顏色,其中 <color> 的 name 必須要和 values/colors.xml 中的相對應。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#3b3b3b</color>
    <color name="colorPrimaryDark">#383838</color>
    <color name="colorAccent">#a72b55</color>
    <color name="textColor">#FFFFFF</color>
    <color name="backgroundColor">#3b3b3b</color>
</resources>

第三步:在values裡建立一個attrs.xml。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="mainBackground" format="color|reference"></attr>
</resources>

第四步:在 styles.xml 中去引用我們在 colors.xml 中定義好的顏色。

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="android:textColor">@color/textColor</item>
        <item name="mainBackground">@color/backgroundColor</item>
    </style>

</resources>

第五步:在佈局中呼叫mainBackground屬性。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="?attr/mainBackground"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_theme"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="切換日/夜間模式" />

</LinearLayout>

第六步:建立一個Application先選擇一個預設的 Mode。

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        // 預設設定為日間模式
        AppCompatDelegate.setDefaultNightMode(
                AppCompatDelegate.MODE_NIGHT_NO);
    }
}

要注意的是,這裡的 Mode 有四種類型可以選擇:

  • MODE_NIGHT_NO: 使用亮色(light)主題,不使用夜間模式;
  • MODE_NIGHT_YES:使用暗色(dark)主題,使用夜間模式;
  • MODE_NIGHT_AUTO:根據當前時間自動切換 亮色(light)/暗色(dark)主題;
  • MODE_NIGHT_FOLLOW_SYSTEM(預設選項):設定為跟隨系統,通常為 MODE_NIGHT_NO

第七步 :在Activity中控制呼叫。

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button btn_theme = (Button) findViewById(R.id.btn_theme);
        btn_theme.setOnClickListener(new View.OnClickListener() {
            @Override public void onClick(View v) {
                int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
                getDelegate().setLocalNightMode(currentNightMode == Configuration.UI_MODE_NIGHT_NO ? AppCompatDelegate.MODE_NIGHT_YES :
                        AppCompatDelegate.MODE_NIGHT_NO); // 同樣需要呼叫recreate方法使之生效 recreate();
                }
        });
    }
}

三、通過資源 id 對映,回撥介面

第三種方法的思路就是根據設定的主題去動態地獲取資源 id 的對映,然後使用回撥介面的方式讓 UI 去設定相關的屬性值。我們在這裡先規定一下:夜間模式的資源在命名上都要加上字尾 “_night” ,比如日間模式的背景色命名為 color_background ,那麼相對應的夜間模式的背景資源就要命名為 color_background_night 。

第一步:在colors.xml中配置。

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <color name="colorPrimary">#008577</color>
    <color name="colorPrimary_night">#3b3b3b</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="colorPrimaryDark_night">#383838</color>
    <color name="colorAccent">#D81B60</color>
    <color name="colorAccent_night">#a72b55</color>
    <color name="textColor">#FF000000</color>
    <color name="textColor_night">#FFFFFF</color>
    <color name="backgroundColor">#FFFFFF</color>
    <color name="backgroundColor_night">#3b3b3b</color>

</resources>

 第二步:建立ThemeManager類。

public class ThemeManager {

    // 預設是日間模式
    private static ThemeMode mThemeMode = ThemeMode.DAY;
    // 主題模式監聽器
    private static List<OnThemeChangeListener> mThemeChangeListenerList = new LinkedList<>();
    // 夜間資源的快取,key : 資源型別, 值<key:資源名稱, value:int值>
    private static HashMap<String, HashMap<String, Integer>> sCachedNightResrouces = new HashMap<>();
    // 夜間模式資源的字尾,比如日件模式資源名為:R.color.activity_bg, 那麼夜間模式就為 :R.color.activity_bg_night
    private static final String RESOURCE_SUFFIX = "_night";

    /**
     * 主題模式,分為日間模式和夜間模式
     */
    public enum ThemeMode {
        DAY, NIGHT
    }

    /**
     * 設定主題模式
     *
     * @param themeMode
     */
    public static void setThemeMode(ThemeMode themeMode) {
        if (mThemeMode != themeMode) {
            mThemeMode = themeMode;
            if (mThemeChangeListenerList.size() > 0) {
                for (OnThemeChangeListener listener : mThemeChangeListenerList) {
                    listener.onThemeChanged();
                }
            }
        }
    }

    /**
     * 根據傳入的日間模式的resId得到相應主題的resId,注意:必須是日間模式的resId
     * * *
     * @param dayResId 日間模式的resId
     * @return 相應主題的resId,若為日間模式,則得到dayResId;反之夜間模式得到nightResId
     */
    public static int getCurrentThemeRes(Context context, int dayResId) {
        if (getThemeMode() == ThemeMode.DAY) {
            return dayResId;
        }
        // 資源名
        String entryName = context.getResources().getResourceEntryName(dayResId);
        // 資源型別
        String typeName = context.getResources().getResourceTypeName(dayResId);
        HashMap<String, Integer> cachedRes = sCachedNightResrouces.get(typeName);
        // 先從快取中去取,如果有直接返回該id
        if (cachedRes == null) {
            cachedRes = new HashMap<>();
        }
        Integer resId = cachedRes.get(entryName + RESOURCE_SUFFIX);
        if (resId != null && resId != 0) {
            return resId;
        } else {
            //如果快取中沒有再根據資源id去動態獲取
            try {
                // 通過資源名,資源型別,包名得到資源int值
                int nightResId = context.getResources().getIdentifier(entryName + RESOURCE_SUFFIX, typeName, context.getPackageName());
                // 放入快取中
                cachedRes.put(entryName + RESOURCE_SUFFIX, nightResId);
                sCachedNightResrouces.put(typeName, cachedRes);
                return nightResId;
            } catch (Resources.NotFoundException e) {
                e.printStackTrace();
            }
        }
        return 0;
    }

    /**
     * 註冊ThemeChangeListener
     *
     * @param listener
     */
    public static void registerThemeChangeListener(OnThemeChangeListener listener) {
        if (!mThemeChangeListenerList.contains(listener)) {
            mThemeChangeListenerList.add(listener);
        }
    }

    /**
     * 反註冊ThemeChangeListener
     *
     * @param listener
     */
    public static void unregisterThemeChangeListener(OnThemeChangeListener listener) {
        if (mThemeChangeListenerList.contains(listener)) {
            mThemeChangeListenerList.remove(listener);
        }
    }

    /**
     * 得到主題模式
     *
     * @return
     */

    public static ThemeMode getThemeMode() {
        return mThemeMode;
    }

    /**
     * 主題模式切換監聽器
     */
    public interface OnThemeChangeListener {
        /**
         * 主題切換時回撥
         */
        void onThemeChanged();
    }
}

上面 ThemeManager 的程式碼基本上都有註釋,想要看懂並不困難。其中最核心的就是 getCurrentThemeRes 方法了。在這裡解釋一下 getCurrentThemeRes 的邏輯。引數中的 dayResId 是日間模式的資源id,如果當前主題是日間模式的話,就直接返回 dayResId 。反之當前主題為夜間模式的話,先根據 dayResId 得到資源名稱和資源型別。比如現在有一個資源為 R.color.colorPrimary ,那麼資源名稱就是 colorPrimary ,資源型別就是 color 。然後根據資源型別和資源名稱去獲取快取。如果沒有快取,那麼就要動態獲取資源了。這裡使用方法的是

context.getResources().getIdentifier(String name, String defType, String defPackage)
  • name 引數就是資源名稱,不過要注意的是這裡的資源名稱還要加上字尾 “_night” ,也就是上面在 colors.xml 中定義的名稱;
  • defType 引數就是資源的型別了。比如 color,drawable等;
  • defPackage 就是資原始檔的包名,也就是當前 APP 的包名。

有了上面的這個方法,就可以通過 R.color.colorPrimary 資源找到對應的 R.color.colorPrimary_night 資源了。最後還要把找到的夜間模式資源加入到快取中。這樣的話以後就直接去快取中讀取,而不用再次去動態查詢資源 id 了。

第三步:佈局。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/relativeLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_theme"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="切換日/夜間模式" />

    <TextView
        android:id="@+id/tv"
        android:layout_below="@id/btn_theme"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:text="通過setTheme()的方法" />

</RelativeLayout>

第三步:在Activity中控制顯示。

public class MainActivity extends AppCompatActivity implements ThemeManager.OnThemeChangeListener {

    private TextView tv;
    private Button btn_theme;
    private RelativeLayout relativeLayout;
    private ActionBar supportActionBar;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ThemeManager.registerThemeChangeListener(this);
        supportActionBar = getSupportActionBar();
        btn_theme = (Button) findViewById(R.id.btn_theme);
        relativeLayout = (RelativeLayout) findViewById(R.id.relativeLayout);
        tv = (TextView) findViewById(R.id.tv);
        btn_theme.setOnClickListener(new View.OnClickListener() {
            @Override public void onClick(View v) {
                ThemeManager.setThemeMode(ThemeManager.getThemeMode() == ThemeManager.ThemeMode.DAY ? ThemeManager.ThemeMode.NIGHT : ThemeManager.ThemeMode.DAY);
            }
        });
    }

    public void initTheme() {
        tv.setTextColor(getResources().getColor(ThemeManager.getCurrentThemeRes(MainActivity.this, R.color.textColor)));
        btn_theme.setTextColor(getResources().getColor(ThemeManager.getCurrentThemeRes(MainActivity.this, R.color.textColor)));
        relativeLayout.setBackgroundColor(getResources().getColor(ThemeManager.getCurrentThemeRes(MainActivity.this, R.color.backgroundColor)));
        // 設定標題欄顏色
        if(supportActionBar != null){
            supportActionBar.setBackgroundDrawable(new ColorDrawable(getResources().getColor(ThemeManager.getCurrentThemeRes(MainActivity.this, R.color.colorPrimary))));
        }
        // 設定狀態列顏色
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            Window window = getWindow();
            window.setStatusBarColor(getResources().getColor(ThemeManager.getCurrentThemeRes(MainActivity.this, R.color.colorPrimary)));
        }
    }

    @Override
    public void onThemeChanged() {
        initTheme();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        ThemeManager.unregisterThemeChangeListener(this);
    }
}

在 MainActivity 中實現了 OnThemeChangeListener 介面,這樣就可以在主題改變的時候執行回撥方法。然後在 initTheme() 中去重新設定 UI 的相關顏色屬性值。還有別忘了要在 onDestroy() 中移除 ThemeChangeListener 。

四、總結

  • setTheme 方法:可以配置多套主題,比較容易上手。除了日/夜間模式之外,還可以有其他五顏六色的主題。但是需要呼叫 recreate() ,切換瞬間會有黑屏閃現的現象;

  • UiMode 方法:優點就是 Android Support Library 中已經支援,簡單規範。但是也需要呼叫 recreate() ,存在黑屏閃現的現象;

  • 動態獲取資源 id ,回撥介面:該方法使用起來比前兩個方法複雜,另外在回撥的方法中需要設定每一項 UI 相關的屬性值。但是不需要呼叫 recreate() ,沒有黑屏閃現的現象。