Android主題切換(Theme)實現日夜間功能


前言

隨著一款APP應用功能的不斷完善,用戶群體的不斷增多,APP的更新也就不僅僅局限于功能需求,如何做好良好的用戶體驗,讓用戶傳播良好的體驗口碑,顯得尤為重要,而用戶體驗一塊日夜間模式儼然成為了標配。其實,日夜間功能就是換膚的一種,關于換膚功能的實現,也是眾說紛紜,總的來講分為兩類:主題換膚(Theme)和插件換膚(APK換膚)。

插件換膚 插件換膚的實現原理就是主APK根據當前環境需求,解析指定目錄下對應的插件APK,獲得其中同名的資源文件并動態替換到主APK的應用程序中。插件APK并不需要安裝,只需要放置在指定目錄下即可。

  • 優點: 能夠實現各種主題樣式的加載,比較靈活,需要增添新的主題只要新建一個插件APK,并配置好相關的資源,放置到指定的文件目錄下就行,很方便。
  • 缺點: 需要對控件進行適配修改,實現換膚功能,對于自定義控件,也需要在適配上花點時間。而且放置在文件夾中的插件APK也可能會因為被誤刪或是損壞而造成資源獲取不到,導致換膚失敗。

主題換膚 主題換膚的實現原理就是在主apk配置多套主題,每套主題對同一個屬性使用相應的資源。

  • 優點: 相比插件換膚來說更容易上手,理解起來也會更容易。
  • 缺點: 增添新的主題樣式必須要發布新版本。全部資源文件都放在APK中,APK會顯得十分臃腫,特別是圖片資源,因此個人推薦純色線條的圖標,并通過著色來實現不同主題下換膚的可能。


因為今天的主題是日夜間模式,考慮到并不會涉及主題樣式增添的可能,所以權衡之下還是選擇使用主題換膚來實現日夜間模式,老套路,效果預覽(文末將附上高清地址入口)

預覽效果第一季槍版
預覽效果第二季槍版




準備相關的屬性樣式及主題:

自定義attr屬性:

主題換膚和插件換膚原理其實一樣,就是控制不同模式下加載對應的資源文件,只是實現的方式不同而已。以往我們在寫xml布局文件的時候,默認的屬性賦值都是絕對的,即android:background="#FFFFFF"android:background="@color/white"
而一旦屬性被這樣賦值,默認的資源加載就被限制,倘若有需求需要視圖在加載時能夠根據當前環境配置特定的資源,那就只能在Java程序代碼中動態修改,繁瑣程度可想而知。那么是否一個辦法能夠使xml屬性的賦值能夠動態的根據當前主題樣式的改變而去加載默認的資源呢 ?
有,那就是今天的腕兒:自定義屬性。在我看來自定義屬性在主題換膚中充當著占位符的角色,它會告訴系統這是一個相對的引用,真正的資源引用是當前上下文環境所對應的主題樣式屬性列表中,對這個自定義屬性的賦值。

1.在res-value目錄下新建attr屬性的資源文件,例如:custom_theme_attrs.xml
2.在custom_theme_attrs.xml文件中新建自定義屬性。

格式:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="自定義屬性名稱" format="資源引用格式(color、dimen、reference...)" />
</resources>

示例:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 控制app背景色 format:顏色值、資源引用 -->
    <attr name="custom_attr_app_bg" format="color|reference" />
    <!-- 控制app標題欄背景色 format:顏色值、資源引用 -->
    <attr name="custom_attr_app_title_layout_bg" format="color|reference" />
    <!-- 用戶頭像顯示占位Drawable format:顏色值、資源引用 -->
    <attr name="custom_attr_user_photo_place_holder" format="color|reference" />
    <!-- 用戶昵稱字體顏色 format:顏色值、資源引用 -->
    <attr name="custom_attr_nickname_text_color" format="color|reference" />
    <!-- 用戶備注字體顏色 format:顏色值、資源引用 -->
    <attr name="custom_attr_remark_text_color" format="color|reference" />
    <!-- 用戶頭像顯示的透明度 format:尺寸值、資源引用 -->
    <attr name="custom_attr_user_photo_alpha" format="dimension|reference" />
</resources>

寫過自定義View的朋友一定不會陌生,不就是自定義屬性嘛。區別就是這些屬性值沒有包裹在styleable中,至于為啥我就不班門弄斧,有需要的朋友可以了解簡書作者楚云之南寫的《深入理解Android 自定義attr Style styleable以及其應用》,感覺寫的不錯,感謝分享 !!

自定義theme主題:

Style想必并不陌生,在需要寫很多類似的代碼塊時,我們通常會提取其中共有部分,配置在Style中,直接在xml中的style屬性中引用即可,非常方便。這里所說的主題其實也是Style樣式中的一種,只是它不僅僅局限于控件樣式屬性的賦值,常常還涉及到window窗口相關,就是樣式屬性的一個集合。既然是通過切換主題來切換應用UI樣式,所以在定義Style主題樣式的時候,需要準備多套主題樣式。

1.在res-value目錄下新建style屬性的資源文件,例如:custom_theme_styles.xml
2.在custom_theme_styles.xml文件中新建自定義主題,并對特定的系統、自定義屬性進行賦值操作。

格式:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="自定義主題樣式的名稱" parent="繼承的主題,可以是自定義主題樣式也可以是系統主題樣式">
        <item name="屬性名稱">賦值的對應資源</item>
    </style>
</resources>

示例:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="MarioTheme" parent="Theme.AppCompat.Light.DarkActionBar" >
        <!-- 隱藏Activity窗口的ActionBar -->
        <item name="windowActionBar">false</item>
        <!-- 隱藏Activity窗口的Title標題欄 -->
        <item name="windowNoTitle">true</item>
    </style>
    
    <style name="MarioTheme.Day" >
        <!-- 日間模式下 "custom_attr_app_bg" 的賦值為#FFFFFF -->
        <item name="custom_attr_app_bg">#FFFFFF</item>
        ...
    </style>
    
    <style name="MarioTheme.Night" >
        <!-- 夜間模式下 "custom_attr_app_bg" 的賦值為#1F1F1F -->
        <item name="custom_attr_app_bg">#1F1F1F</item>
        ...
    </style>
</resources>

如上述示例所示,首先是新建一個繼承自系統Theme.AppCompat.Light.DarkActionBar樣式的自定義主題MarioTheme算是一個主題的Base基礎主題,在這個基礎主題中,可以對一些通用的屬性進行賦值,比如一些全局性的窗口樣式,當然這些賦值上去的屬性也是可以被后來繼承的子類主題覆蓋。
然后又新建了兩個繼承自這個基礎主題的MarioTheme.DayMarioTheme.Night分別作為日間和夜間的主題,而且分別在兩個主題中對自定義屬性custom_attr_app_bg進行了賦值。

其實通過上述兩個步驟:[自定義屬性 --> 自定義主題,并在主題中對自定義屬性進行相應的賦值],主題換膚的準備工作可以說是已經完成。但是為了項目的可維護性更高,尚且有不少可以優化的地方,如上#1F1F1F顏色值直接出現在style中。這是我非常反對的一種操作方式,在使用主題換膚的應用中,隨著應用功能的強大,自定義屬性的數量一定會越來越多,而且我覺得自定義屬性定義的越精細越好,所以一定會有一個龐大數量的屬性列表需要去維護。其中也有可能大部分是可以被重復使用的,何不將它們整理到統一的文件中,倘若到時候需求變化,資源引用需要修改,也不至于全局搜索挨個去改,何必給自己增加這么多沒有必要的工作量呢 ! 所以我還要講講自定義屬性 。




自定義resource資源:

同類型的資源新建在對應的目錄下,尺寸資源定義在values-dimens目錄下,顏色資源定義再values-colors目錄下,drawable資源定義在values目錄下對應的drawable目錄下... 并且每一種資源都應該根據不同主題樣式配置多套。


自定義color:

1.在res-value目錄下新建color屬性的資源文件,例如:custom_theme_colors.xml
2.在custom_theme_colors.xml文件中新建自定義color顏色。

格式:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="自定義color名稱_day">對應的日間顏色值</color>
    <color name="自定義color名稱_night">對應的夜間顏色值</color>
</resources>

定義app背景色為例:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 日間模式下app背景色 -->
    <color name="custom_color_app_bg_day">#FFFFFF</color>
    <!-- 日間模式下app標題欄背景色 -->
    <color name="custom_color_app_title_layout_bg_day">#FF2F3A4C</color>        
    <!-- 夜間模式下app背景色 -->
    <color name="custom_color_app_bg_night">#1F1F1F</color>
    <!-- 夜間模式下app標題欄背景色 -->  
    <color name="custom_color_app_title_layout_bg_night">#FF1D1D1D</color> 
    ... 
</resources>


自定義drawable:

1.在res目錄下新建drawable文件夾。
1.在res-drawable目錄下新建drawable資源文件。

定義圓形圖片的占位drawable,示例:

custom_drawable_user_photo_place_holder_day.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/custom_color_user_photo_place_holder_bg_day" />
    <corners android:radius="32dp" />
</shape>

custom_drawable_user_photo_place_holder_night.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/custom_color_user_photo_place_holder_bg_night" />
    <corners android:radius="32dp" />
</shape>

自定義drawable中使用到的顏色值推薦也統一整理到custom_theme_colors.xml文件中。

<!-- 用戶頭像占位drawable背景顏色 -->
<color name="custom_color_user_photo_place_holder_bg_day">#29303B</color>
<color name="custom_color_user_photo_place_holder_bg_night">#171717</color>


自定義colorStateList:

同SelectorDrawable一樣,color也可以設置Selector選擇器。
1.value目錄下新建color.xml文件。
2.在res-color.xml目錄下新建color資源文件。

示例:

custom_selector_text_day.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/custom_color_text_pressed_day" android:state_pressed="true" />
    <item android:color="@color/custom_color_text_day" />
</selector>

custom_selector_text_night.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/custom_color_text_pressed_night" android:state_pressed="true" />
    <item android:color="@color/custom_color_text_night" />
</selector>

同理,自定義colorStateList中使用到的顏色值推薦也統一整理到custom_theme_colors.xml文件中。


在不同的主題樣式下為自定義屬性賦值:

示例:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    // 日間相關屬性集
    <style name="MarioTheme.Day" >
        <item name="custom_attr_app_bg">@color/custom_color_app_bg_day</item>
        <item name="custom_attr_app_title_layout_bg">@color/custom_color_app_title_layout_bg_day</item>
        <item name="custom_attr_user_photo_place_holder">@drawable/custom_drawable_user_photo_place_holder_day</item>
    </style>
    // 夜間相關屬性集
    <style name="MarioTheme.Night" >
        <item name="custom_attr_app_bg">@color/custom_color_app_bg_night</item>
        <item name="custom_attr_app_title_layout_bg">@color/custom_color_app_title_layout_bg_night</item>
        <item name="custom_attr_user_photo_place_holder">@drawable/custom_drawable_user_photo_place_holder_night</item>
    </style>
</resources>

到這里就完成了相關的準備工作。因為在日夜間模式切換中基本不太會涉及字符串、尺寸的資源樣式的修改,實現的方式是一樣的,因此不做過多的贅述,有需要的朋友可以自定義去嘗試。






在XML布局文件中使用自定義屬性:

只要前期準備工作做好了使用起來其實是非常簡單的,就是在屬性賦值的時候不再使用絕對的資源引用,而是引用已經完成賦值的自定義的屬性:

android:需要修改的屬性="?attr/自定義屬性名稱" 

這樣的話只要設置自定義屬性的View控件的Context上下文環境設置了對應的Theme主題樣式,且對我們的自定義樣式進行了相應的賦值,則樣式的使用就會奏效,切記,項目中使用到的屬性一定要在使用的主題樣式下賦值,否則應用運行的時候會報錯。當然為了更好的開發體驗,我們可以在預覽模式下設置對應的主題預覽我們設置的樣式效果是否起效,效果怎么樣。 ?

Demo部分布局代碼展示,示例:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="?attr/custom_attr_app_bg"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    android:orientation="vertical">

    <View
        android:background="?attr/custom_attr_app_title_layout_bg"
        android:id="@id/custom_id_title_status_bar"
        android:layout_width="match_parent"
        android:layout_height="0dp" />

    <RelativeLayout
        android:background="?attr/custom_attr_app_title_layout_bg"
        android:id="@id/custom_id_title_layout"
        android:layout_width="match_parent"
        android:layout_height="136dp"
        android:paddingBottom="16dp"
        android:paddingRight="12dp"
        android:paddingLeft="12dp"
        android:paddingTop="8dp" >

        <ImageView
            android:padding="3dp"
            android:layout_width="72dp"
            android:layout_height="72dp"
            android:id="@+id/theme_user_photo"
            android:layout_alignParentLeft="true"
            android:layout_alignParentBottom="true"
            android:alpha="?attr/custom_attr_user_photo_alpha"
            tools:src="?attr/custom_attr_user_photo_place_holder" />

        <LinearLayout
            android:layout_toRightOf="@+id/theme_user_photo"
            android:layout_alignTop="@+id/theme_user_photo"
            android:layout_width="wrap_content"
            android:gravity="center_vertical"
            android:layout_marginLeft="12dp"
            android:orientation="vertical"
            android:layout_height="72dp" >

            <TextView
                android:textColor="?attr/custom_attr_nickname_text_color"
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:id="@+id/theme_nickname"
                android:text="@string/nickname"
                android:textSize="19dp" />

            <TextView
                android:textColor="?attr/custom_attr_remark_text_color"
                android:text="@string/remark"
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:layout_marginTop="3dp"
                android:id="@+id/theme_remark"
                android:textSize="12dp" />
        </LinearLayout>
    </RelativeLayout>
</LinearLayout>
日間預覽
夜間預覽

預覽中的兩個主題MarioTheme.Day.PreviewMarioTheme.Night.Preview分別繼承之MarioTheme.DayMarioTheme.Night,并沒有在項目中使用起來,主要用來控制狀態欄的顏色,個人用于編輯器狀態欄沉浸效果的一個預覽效果。

到這一步,在Activity中使用setTheme()就能加載對應主題的視圖啦 !! 有沒有很贊 ?
需要注意的一點是 setTheme()方法必須要在系統調用setContentView()方法前調用,個人推薦統一寫到基類BaseActivity的onCreate()方法中。而我們需要做的就是在本地SharePreference中配置一個tag控制BaseActivity設置不同的主題就行啦 !
也許看到這里你已經躍躍欲試,或者你已經一步一步照著寫到這里,但是應用跑起來卻發現,在Activity中點擊按鈕調用setTheme()方法,Activity并不會發生變化,或者返回上一個Activity也是沒有變化。并不是setTheme()方法沒有奏效,setTheme()方法確實起到應有的效果了(可以調用getTheme()方法查看,當前主題確實已經改變)。那又是什么原因呢? 那是因為這些視圖都是已經加載完成,設置主題并不會觸發系統去刷新UI,因此需要我們手動去觸發。

而更改主題后的UI刷新我推薦兩種:

  • 重新創建Activity 關于重新創建Activity,只需要調用Activity的recreate()方法就行,普通不復雜的UI,用這個方法基本可以滿足,其中主要涉onSaveInstanceState()應用狀態的保存,而使用這種方法重新創建Activity也是Google官方比較推崇的,有興趣可以了解一下
  • 手動加載當前主題下的應用資源 這是我這里需要重點講一下的。由于UI的復雜性和特殊性,并不是所有應用的Activity都可以通過onSaveInstanceState()來保存當前的應用狀態的,因此了解如何從當前主題獲取需要的屬性資源顯得尤為重要。


獲得當前主題自定義屬性指定的資源:

其實獲取這個資源也很簡單,也就兩步:

  • Step-01 獲取TypedValue

      TypedValue typedValue = new TypedValue(); 
      Resources.Theme theme = getTheme(); 
      try {
          theme.resolveAttribute(R.attr.自定義屬性, typedValue, true);
      } catch (Exception e) {
          e.printStackTrace();
      }
    

首先定義一個TypedValue用于承載Resource資源屬性,然后獲取當前上下文對應的Theme主題,再是通過resolveAttribute()方法獲取當前主題下給定屬性ID對應的資源信息并賦值給定義好的typedValue。因為可能存在給定屬性對應的資源信息獲取不到而拋出的異常,所以建議try&catch一下,捕獲可能存在的異常情況


  • Step-02 根據獲取的TypedValue所包含的資源信息獲取對應的資源

      Resources resources = getResources();
          try {
              int color = ResourcesCompat.getColor(resources, typedValue.resourceId, null); // 獲取顏色值
              Drawable drawable = ResourcesCompat.getDrawable(resources, typedValue.resourceId, null); // 獲取Drawable對象
              String string = resources.getString(typedValue.resourceId); // 獲取字符串
          } catch (Exception e) {
              e.printStackTrace();
          }
    

TypedValue最重要的一個屬性就是resourceId,只要確定獲取的typedValue不為null。我們就可以通過typedValue.resourceId獲取資源的id,就好比知道了一個顏色資源的ID是R.color.black,讓你去獲取顏色值,知道一個Drawable資源的ID是R.drawable.ic_luncher,讓你去獲取Drawable對象,想想就簡單(捂臉.jpg)。需要注意的是在獲取對應資源的時候為避免資源獲取失敗拋出的異常,各種獲取資源的方法還是建議用try&catch包裹一下。關于資源獲取,文末給出的Demo中有一個MarioResourceHelper的輔助類,該類對資源獲取一塊進行了一個小封裝,用起來會更加方便。

而接下來需要做的就是對特定的資源進行替換就好了。

補充一點:

關于前文提到主題換膚缺點時,其中一點就是所有資源文件都需要放置在主APK文件中打包發布,也許不同的主題就會有多套圖片資源,在Android有限內存的條件下,這是一種非常糟糕的情況。
而在前文我也提及,應對這種現象,我們開發能做的就是使用drawable著色的方式,盡量用一套圖片資源實現多種主題。切記! 著色的圖片要求純色且背景透明的PNG,因為著色并不能區分色彩,而是對所有非透明區域統一著色上指定的顏色。著色細節不做贅述,線上《Drawable著色的后向兼容》一文闡述的比較詳細了吧,感謝作者分享 !!而我們要做的就是將需要的著色上去的顏色值定義在不同的主題下,不同主題獲取對應的顏色值,并對特定的drawable進行著色即可。 而MarioResourceHelper輔助類也會對drawable的著色方法做相應的封裝。

? Github項目源碼地址 ?

最后附上前文兩張動圖的原版錄制視頻,觀看效果更佳 !!

預覽效果第一季藍光
預覽效果第二季藍光

作者申明:如果文中有表述不當或闡述錯誤的地方,還望正在看文章的您可以幫忙指出,有疑惑也可以在評論區提問或者私信,期待您的意見和建議,歡迎關注交流,轉載請注明出處 !

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,572評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,071評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,409評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,569評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,360評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,895評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,979評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,123評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,643評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,559評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,742評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,250評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,981評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,363評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,622評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,354評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,707評論 2 370

推薦閱讀更多精彩內容