
前言
隨著一款A(yù)PP應(yīng)用功能的不斷完善,用戶群體的不斷增多,APP的更新也就不僅僅局限于功能需求,如何做好良好的用戶體驗(yàn),讓用戶傳播良好的體驗(yàn)口碑,顯得尤為重要,而用戶體驗(yàn)一塊日夜間模式儼然成為了標(biāo)配。其實(shí),日夜間功能就是換膚的一種,關(guān)于換膚功能的實(shí)現(xiàn),也是眾說紛紜,總的來講分為兩類:主題換膚(Theme)和插件換膚(APK換膚)。
插件換膚 插件換膚的實(shí)現(xiàn)原理就是主APK根據(jù)當(dāng)前環(huán)境需求,解析指定目錄下對(duì)應(yīng)的插件APK,獲得其中同名的資源文件并動(dòng)態(tài)替換到主APK的應(yīng)用程序中。插件APK并不需要安裝,只需要放置在指定目錄下即可。
- 優(yōu)點(diǎn): 能夠?qū)崿F(xiàn)各種主題樣式的加載,比較靈活,需要增添新的主題只要新建一個(gè)插件APK,并配置好相關(guān)的資源,放置到指定的文件目錄下就行,很方便。
- 缺點(diǎn): 需要對(duì)控件進(jìn)行適配修改,實(shí)現(xiàn)換膚功能,對(duì)于自定義控件,也需要在適配上花點(diǎn)時(shí)間。而且放置在文件夾中的插件APK也可能會(huì)因?yàn)楸徽`刪或是損壞而造成資源獲取不到,導(dǎo)致?lián)Q膚失敗。
主題換膚 主題換膚的實(shí)現(xiàn)原理就是在主apk配置多套主題,每套主題對(duì)同一個(gè)屬性使用相應(yīng)的資源。
- 優(yōu)點(diǎn): 相比插件換膚來說更容易上手,理解起來也會(huì)更容易。
- 缺點(diǎn): 增添新的主題樣式必須要發(fā)布新版本。全部資源文件都放在APK中,APK會(huì)顯得十分臃腫,特別是圖片資源,因此個(gè)人推薦純色線條的圖標(biāo),并通過著色來實(shí)現(xiàn)不同主題下?lián)Q膚的可能。
因?yàn)榻裉斓闹黝}是日夜間模式,考慮到并不會(huì)涉及主題樣式增添的可能,所以權(quán)衡之下還是選擇使用主題換膚來實(shí)現(xiàn)日夜間模式,老套路,效果預(yù)覽(文末將附上高清地址入口)


準(zhǔn)備相關(guān)的屬性樣式及主題:
自定義attr屬性:
主題換膚和插件換膚原理其實(shí)一樣,就是控制不同模式下加載對(duì)應(yīng)的資源文件,只是實(shí)現(xiàn)的方式不同而已。以往我們?cè)趯憍ml布局文件的時(shí)候,默認(rèn)的屬性賦值都是絕對(duì)的,即android:background="#FFFFFF"或android:background="@color/white"。
而一旦屬性被這樣賦值,默認(rèn)的資源加載就被限制,倘若有需求需要視圖在加載時(shí)能夠根據(jù)當(dāng)前環(huán)境配置特定的資源,那就只能在Java程序代碼中動(dòng)態(tài)修改,繁瑣程度可想而知。那么是否一個(gè)辦法能夠使xml屬性的賦值能夠動(dòng)態(tài)的根據(jù)當(dāng)前主題樣式的改變而去加載默認(rèn)的資源呢 ?
有,那就是今天的腕兒:自定義屬性。在我看來自定義屬性在主題換膚中充當(dāng)著占位符的角色,它會(huì)告訴系統(tǒng)這是一個(gè)相對(duì)的引用,真正的資源引用是當(dāng)前上下文環(huán)境所對(duì)應(yīng)的主題樣式屬性列表中,對(duì)這個(gè)自定義屬性的賦值。
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標(biāo)題欄背景色 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的朋友一定不會(huì)陌生,不就是自定義屬性嘛。區(qū)別就是這些屬性值沒有包裹在styleable中,至于為啥我就不班門弄斧,有需要的朋友可以了解簡(jiǎn)書作者楚云之南寫的《深入理解Android 自定義attr Style styleable以及其應(yīng)用》,感覺寫的不錯(cuò),感謝分享 ??!
自定義theme主題:
Style想必并不陌生,在需要寫很多類似的代碼塊時(shí),我們通常會(huì)提取其中共有部分,配置在Style中,直接在xml中的style屬性中引用即可,非常方便。這里所說的主題其實(shí)也是Style樣式中的一種,只是它不僅僅局限于控件樣式屬性的賦值,常常還涉及到window窗口相關(guān),就是樣式屬性的一個(gè)集合。既然是通過切換主題來切換應(yīng)用UI樣式,所以在定義Style主題樣式的時(shí)候,需要準(zhǔn)備多套主題樣式。
1.在res-value目錄下新建style屬性的資源文件,例如:custom_theme_styles.xml。
2.在custom_theme_styles.xml文件中新建自定義主題,并對(duì)特定的系統(tǒng)、自定義屬性進(jìn)行賦值操作。
格式:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="自定義主題樣式的名稱" parent="繼承的主題,可以是自定義主題樣式也可以是系統(tǒng)主題樣式">
<item name="屬性名稱">賦值的對(duì)應(yīng)資源</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標(biāo)題欄 -->
<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>
如上述示例所示,首先是新建一個(gè)繼承自系統(tǒng)Theme.AppCompat.Light.DarkActionBar樣式的自定義主題MarioTheme算是一個(gè)主題的Base基礎(chǔ)主題,在這個(gè)基礎(chǔ)主題中,可以對(duì)一些通用的屬性進(jìn)行賦值,比如一些全局性的窗口樣式,當(dāng)然這些賦值上去的屬性也是可以被后來繼承的子類主題覆蓋。
然后又新建了兩個(gè)繼承自這個(gè)基礎(chǔ)主題的MarioTheme.Day和MarioTheme.Night分別作為日間和夜間的主題,而且分別在兩個(gè)主題中對(duì)自定義屬性custom_attr_app_bg進(jìn)行了賦值。
其實(shí)通過上述兩個(gè)步驟:[自定義屬性 --> 自定義主題,并在主題中對(duì)自定義屬性進(jìn)行相應(yīng)的賦值],主題換膚的準(zhǔn)備工作可以說是已經(jīng)完成。但是為了項(xiàng)目的可維護(hù)性更高,尚且有不少可以優(yōu)化的地方,如上#1F1F1F顏色值直接出現(xiàn)在style中。這是我非常反對(duì)的一種操作方式,在使用主題換膚的應(yīng)用中,隨著應(yīng)用功能的強(qiáng)大,自定義屬性的數(shù)量一定會(huì)越來越多,而且我覺得自定義屬性定義的越精細(xì)越好,所以一定會(huì)有一個(gè)龐大數(shù)量的屬性列表需要去維護(hù)。其中也有可能大部分是可以被重復(fù)使用的,何不將它們整理到統(tǒng)一的文件中,倘若到時(shí)候需求變化,資源引用需要修改,也不至于全局搜索挨個(gè)去改,何必給自己增加這么多沒有必要的工作量呢 ! 所以我還要講講自定義屬性 。
自定義resource資源:
同類型的資源新建在對(duì)應(yīng)的目錄下,尺寸資源定義在values-dimens目錄下,顏色資源定義再values-colors目錄下,drawable資源定義在values目錄下對(duì)應(yīng)的drawable目錄下... 并且每一種資源都應(yīng)該根據(jù)不同主題樣式配置多套。
自定義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">對(duì)應(yīng)的日間顏色值</color>
<color name="自定義color名稱_night">對(duì)應(yīng)的夜間顏色值</color>
</resources>
定義app背景色為例:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 日間模式下app背景色 -->
<color name="custom_color_app_bg_day">#FFFFFF</color>
<!-- 日間模式下app標(biāo)題欄背景色 -->
<color name="custom_color_app_title_layout_bg_day">#FF2F3A4C</color>
<!-- 夜間模式下app背景色 -->
<color name="custom_color_app_bg_night">#1F1F1F</color>
<!-- 夜間模式下app標(biāo)題欄背景色 -->
<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中使用到的顏色值推薦也統(tǒng)一整理到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也可以設(shè)置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中使用到的顏色值推薦也統(tǒng)一整理到custom_theme_colors.xml文件中。
在不同的主題樣式下為自定義屬性賦值:
示例:
<?xml version="1.0" encoding="utf-8"?>
<resources>
// 日間相關(guān)屬性集
<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>
// 夜間相關(guān)屬性集
<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>
到這里就完成了相關(guān)的準(zhǔn)備工作。因?yàn)樵谌找归g模式切換中基本不太會(huì)涉及字符串、尺寸的資源樣式的修改,實(shí)現(xiàn)的方式是一樣的,因此不做過多的贅述,有需要的朋友可以自定義去嘗試。
在XML布局文件中使用自定義屬性:
只要前期準(zhǔn)備工作做好了使用起來其實(shí)是非常簡(jiǎn)單的,就是在屬性賦值的時(shí)候不再使用絕對(duì)的資源引用,而是引用已經(jīng)完成賦值的自定義的屬性:
android:需要修改的屬性="?attr/自定義屬性名稱"
這樣的話只要設(shè)置自定義屬性的View控件的Context上下文環(huán)境設(shè)置了對(duì)應(yīng)的Theme主題樣式,且對(duì)我們的自定義樣式進(jìn)行了相應(yīng)的賦值,則樣式的使用就會(huì)奏效,切記,項(xiàng)目中使用到的屬性一定要在使用的主題樣式下賦值,否則應(yīng)用運(yùn)行的時(shí)候會(huì)報(bào)錯(cuò)。當(dāng)然為了更好的開發(fā)體驗(yàn),我們可以在預(yù)覽模式下設(shè)置對(duì)應(yīng)的主題預(yù)覽我們?cè)O(shè)置的樣式效果是否起效,效果怎么樣。 ?
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>


預(yù)覽中的兩個(gè)主題MarioTheme.Day.Preview和MarioTheme.Night.Preview分別繼承之MarioTheme.Day和MarioTheme.Night,并沒有在項(xiàng)目中使用起來,主要用來控制狀態(tài)欄的顏色,個(gè)人用于編輯器狀態(tài)欄沉浸效果的一個(gè)預(yù)覽效果。
到這一步,在Activity中使用setTheme()就能加載對(duì)應(yīng)主題的視圖啦 ??! 有沒有很贊 ?
需要注意的一點(diǎn)是 setTheme()方法必須要在系統(tǒng)調(diào)用setContentView()方法前調(diào)用,個(gè)人推薦統(tǒng)一寫到基類BaseActivity的onCreate()方法中。而我們需要做的就是在本地SharePreference中配置一個(gè)tag控制BaseActivity設(shè)置不同的主題就行啦 !
也許看到這里你已經(jīng)躍躍欲試,或者你已經(jīng)一步一步照著寫到這里,但是應(yīng)用跑起來卻發(fā)現(xiàn),在Activity中點(diǎn)擊按鈕調(diào)用setTheme()方法,Activity并不會(huì)發(fā)生變化,或者返回上一個(gè)Activity也是沒有變化。并不是setTheme()方法沒有奏效,setTheme()方法確實(shí)起到應(yīng)有的效果了(可以調(diào)用getTheme()方法查看,當(dāng)前主題確實(shí)已經(jīng)改變)。那又是什么原因呢? 那是因?yàn)檫@些視圖都是已經(jīng)加載完成,設(shè)置主題并不會(huì)觸發(fā)系統(tǒng)去刷新UI,因此需要我們手動(dòng)去觸發(fā)。
而更改主題后的UI刷新我推薦兩種:
-
重新創(chuàng)建Activity關(guān)于重新創(chuàng)建Activity,只需要調(diào)用Activity的recreate()方法就行,普通不復(fù)雜的UI,用這個(gè)方法基本可以滿足,其中主要涉onSaveInstanceState()應(yīng)用狀態(tài)的保存,而使用這種方法重新創(chuàng)建Activity也是Google官方比較推崇的,有興趣可以了解一下。 -
手動(dòng)加載當(dāng)前主題下的應(yīng)用資源這是我這里需要重點(diǎn)講一下的。由于UI的復(fù)雜性和特殊性,并不是所有應(yīng)用的Activity都可以通過onSaveInstanceState()來保存當(dāng)前的應(yīng)用狀態(tài)的,因此了解如何從當(dāng)前主題獲取需要的屬性資源顯得尤為重要。
獲得當(dāng)前主題自定義屬性指定的資源:
其實(shí)獲取這個(gè)資源也很簡(jiǎn)單,也就兩步:
-
Step-01 獲取TypedValueTypedValue typedValue = new TypedValue(); Resources.Theme theme = getTheme(); try { theme.resolveAttribute(R.attr.自定義屬性, typedValue, true); } catch (Exception e) { e.printStackTrace(); }
首先定義一個(gè)TypedValue用于承載Resource資源屬性,然后獲取當(dāng)前上下文對(duì)應(yīng)的Theme主題,再是通過resolveAttribute()方法獲取當(dāng)前主題下給定屬性ID對(duì)應(yīng)的資源信息并賦值給定義好的typedValue。因?yàn)榭赡艽嬖诮o定屬性對(duì)應(yīng)的資源信息獲取不到而拋出的異常,所以建議try&catch一下,捕獲可能存在的異常情況
-
Step-02 根據(jù)獲取的TypedValue所包含的資源信息獲取對(duì)應(yīng)的資源Resources resources = getResources(); try { int color = ResourcesCompat.getColor(resources, typedValue.resourceId, null); // 獲取顏色值 Drawable drawable = ResourcesCompat.getDrawable(resources, typedValue.resourceId, null); // 獲取Drawable對(duì)象 String string = resources.getString(typedValue.resourceId); // 獲取字符串 } catch (Exception e) { e.printStackTrace(); }
TypedValue最重要的一個(gè)屬性就是resourceId,只要確定獲取的typedValue不為null。我們就可以通過typedValue.resourceId獲取資源的id,就好比知道了一個(gè)顏色資源的ID是R.color.black,讓你去獲取顏色值,知道一個(gè)Drawable資源的ID是R.drawable.ic_luncher,讓你去獲取Drawable對(duì)象,想想就簡(jiǎn)單(捂臉.jpg)。需要注意的是在獲取對(duì)應(yīng)資源的時(shí)候?yàn)楸苊赓Y源獲取失敗拋出的異常,各種獲取資源的方法還是建議用try&catch包裹一下。關(guān)于資源獲取,文末給出的Demo中有一個(gè)MarioResourceHelper的輔助類,該類對(duì)資源獲取一塊進(jìn)行了一個(gè)小封裝,用起來會(huì)更加方便。
而接下來需要做的就是對(duì)特定的資源進(jìn)行替換就好了。
補(bǔ)充一點(diǎn):
關(guān)于前文提到主題換膚缺點(diǎn)時(shí),其中一點(diǎn)就是所有資源文件都需要放置在主APK文件中打包發(fā)布,也許不同的主題就會(huì)有多套圖片資源,在Android有限內(nèi)存的條件下,這是一種非常糟糕的情況。
而在前文我也提及,應(yīng)對(duì)這種現(xiàn)象,我們開發(fā)能做的就是使用drawable著色的方式,盡量用一套圖片資源實(shí)現(xiàn)多種主題。切記! 著色的圖片要求純色且背景透明的PNG,因?yàn)橹⒉荒軈^(qū)分色彩,而是對(duì)所有非透明區(qū)域統(tǒng)一著色上指定的顏色。著色細(xì)節(jié)不做贅述,線上《Drawable著色的后向兼容》一文闡述的比較詳細(xì)了吧,感謝作者分享 ??!而我們要做的就是將需要的著色上去的顏色值定義在不同的主題下,不同主題獲取對(duì)應(yīng)的顏色值,并對(duì)特定的drawable進(jìn)行著色即可。 而MarioResourceHelper輔助類也會(huì)對(duì)drawable的著色方法做相應(yīng)的封裝。
最后附上前文兩張動(dòng)圖的原版錄制視頻,觀看效果更佳 !!
預(yù)覽效果第一季藍(lán)光
預(yù)覽效果第二季藍(lán)光
作者申明:如果文中有表述不當(dāng)或闡述錯(cuò)誤的地方,還望正在看文章的您可以幫忙指出,有疑惑也可以在評(píng)論區(qū)提問或者私信,期待您的意見和建議,歡迎關(guān)注交流,轉(zhuǎn)載請(qǐng)注明出處 !