為什么會有換膚的需求
app的換膚,可以降低app用戶的審美疲勞。再好的UI設計,一直不變的話,也會對用戶體驗大打折扣,即使表面上不說,但心里或多或少會有些難受。所以app的界面要適當的改版啊,要不然可難受死用戶了,特別是UI設計還相對較丑的。
換膚是什么
換膚是將app的背景色、文字顏色以及資源圖片,一鍵進行全部切換的過程。這里就包括了圖片資源和顏色資源。
Skins怎么使用
Skins就是一個解決這樣一種換膚需求的框架。
// 添加以下代碼到項目根目錄下的build.gradle
allprojects {repositories {maven { url "https://jitpack.io" }}
}
// 添加以下代碼到app模塊的build.gradle
dependencies {// skins依賴了dora框架,所以你也要implementation doraimplementation("com.github.dora4:dora:1.1.12")implementation 'com.github.dora4:dview-skins:1.4'
}
我以更換皮膚顏色為例,打開res/colors.xml。
<!-- 需要換膚的顏色 -->
<color name="skin_theme_color">@color/cyan</color>
<color name="skin_theme_color_red">#d23c3e</color>
<color name="skin_theme_color_orange">#ff8400</color>
<color name="skin_theme_color_black">#161616</color>
<color name="skin_theme_color_green">#009944</color>
<color name="skin_theme_color_blue">#0284e9</color>
<color name="skin_theme_color_cyan">@color/cyan</color>
<color name="skin_theme_color_purple">#8c00d6</color>
將所有需要換膚的顏色,添加skin_前綴和_skinname后綴,不加后綴的就是默認皮膚。
然后在啟動頁應用預設的皮膚類型。在布局layout文件中使用默認皮膚的資源名稱,像這里就是R.color.skin_theme_color,框架會自動幫你替換。要想讓框架自動幫你替換,你需要讓所有要換膚的Activity繼承BaseSkinActivity。
private fun applySkin() {val manager = PreferencesManager(this)when (manager.getSkinType()) {0 -> {}1 -> {SkinManager.changeSkin("cyan")}2 -> {SkinManager.changeSkin("orange")}3 -> {SkinManager.changeSkin("black")}4 -> {SkinManager.changeSkin("green")}5 -> {SkinManager.changeSkin("red")}6 -> {SkinManager.changeSkin("blue")}7 -> {SkinManager.changeSkin("purple")}}
}
另外還有一個情況是在代碼中使用換膚,那么跟布局文件中定義是有一些區別的。
val skinThemeColor = SkinManager.getLoader().getColor("skin_theme_color")
這個skinThemeColor拿到的就是當前皮膚下的真正的skin_theme_color顏色,比如R.color.skin_theme_color_orange的顏色值“#ff8400”或R.id.skin_theme_color_blue的顏色值“#0284e9”。
SkinLoader還提供了更簡潔設置View顏色的方法。
override fun setImageDrawable(imageView: ImageView, resName: String) {val drawable = getDrawable(resName) ?: returnimageView.setImageDrawable(drawable)
}override fun setBackgroundDrawable(view: View, resName: String) {val drawable = getDrawable(resName) ?: returnview.background = drawable
}override fun setBackgroundColor(view: View, resName: String) {val color = getColor(resName)view.setBackgroundColor(color)
}
框架原理解析
先看BaseSkinActivity的源碼。
package dora.skin.baseimport android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.InflateException
import android.view.LayoutInflater
import android.view.View
import androidx.collection.ArrayMap
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.LayoutInflaterFactory
import androidx.databinding.ViewDataBinding
import dora.BaseActivity
import dora.skin.SkinManager
import dora.skin.attr.SkinAttr
import dora.skin.attr.SkinAttrSupport
import dora.skin.attr.SkinView
import dora.skin.listener.ISkinChangeListener
import dora.util.LogUtils
import dora.util.ReflectionUtils
import java.lang.reflect.Constructor
import java.lang.reflect.Method
import java.util.*abstract class BaseSkinActivity<T : ViewDataBinding> : BaseActivity<T>(),ISkinChangeListener, LayoutInflaterFactory {private val constructorArgs = arrayOfNulls<Any>(2)override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {if (createViewMethod == null) {val methodOnCreateView = ReflectionUtils.findMethod(delegate.javaClass, false,"createView", *createViewSignature)createViewMethod = methodOnCreateView}var view: View? = ReflectionUtils.invokeMethod(delegate, createViewMethod, parent, name,context, attrs) as View?if (view == null) {view = createViewFromTag(context, name, attrs)}val skinAttrList = SkinAttrSupport.getSkinAttrs(attrs, context)if (skinAttrList.isEmpty()) {return view}injectSkin(view, skinAttrList)return view}private fun injectSkin(view: View?, skinAttrList: MutableList<SkinAttr>) {if (skinAttrList.isNotEmpty()) {var skinViews = SkinManager.getSkinViews(this)if (skinViews == null) {skinViews = arrayListOf()}skinViews.add(SkinView(view, skinAttrList))SkinManager.addSkinView(this, skinViews)if (SkinManager.needChangeSkin()) {SkinManager.apply(this)}}}private fun createViewFromTag(context: Context, viewName: String, attrs: AttributeSet): View? {var name = viewNameif (name == "view") {name = attrs.getAttributeValue(null, "class")}return try {constructorArgs[0] = contextconstructorArgs[1] = attrsif (-1 == name.indexOf('.')) {// try the android.widget prefix first...createView(context, name, "android.widget.")} else {createView(context, name, null)}} catch (e: Exception) {// We do not want to catch these, lets return null and let the actual LayoutInflaternull} finally {// Don't retain references on context.constructorArgs[0] = nullconstructorArgs[1] = null}}@Throws(InflateException::class)private fun createView(context: Context, name: String, prefix: String?): View? {var constructor = constructorMap[name]return try {if (constructor == null) {// Class not found in the cache, see if it's real, and try to add itval clazz = context.classLoader.loadClass(if (prefix != null) prefix + name else name).asSubclass(View::class.java)constructor = clazz.getConstructor(*constructorSignature)constructorMap[name] = constructor}constructor!!.isAccessible = trueconstructor.newInstance(*constructorArgs)} catch (e: Exception) {// We do not want to catch these, lets return null and let the actual LayoutInflaternull}}override fun onCreate(savedInstanceState: Bundle?) {val layoutInflater = LayoutInflater.from(this)LayoutInflaterCompat.setFactory(layoutInflater, this)super.onCreate(savedInstanceState)SkinManager.addListener(this)}override fun onDestroy() {super.onDestroy()SkinManager.removeListener(this)}override fun onSkinChanged(suffix: String) {SkinManager.apply(this)}companion object {val constructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)private val constructorMap: MutableMap<String, Constructor<out View>> = ArrayMap()private var createViewMethod: Method? = nullval createViewSignature = arrayOf(View::class.java, String::class.java,Context::class.java, AttributeSet::class.java)}
}
我們可以看到BaseSkinActivity繼承自dora.BaseActivity,所以dora框架是必須要依賴的。有人說,那我不用dora框架的功能,可不可以不依賴dora框架?我的回答是,不建議。Skins對Dora生命周期注入特性采用的是,依賴即配置。
package dora.lifecycle.applicationimport android.app.Application
import android.content.Context
import dora.skin.SkinManagerclass SkinsAppLifecycle : ApplicationLifecycleCallbacks {override fun attachBaseContext(base: Context) {}override fun onCreate(application: Application) {SkinManager.init(application)}override fun onTerminate(application: Application) {}
}
所以你無需手動配置<meta-data android:name="dora.lifecycle.config.SkinsGlobalConfig" android:value="GlobalConfig"/>
,Skins已經自動幫你配置好了。那么我順便問個問題,BaseSkinActivity中最關鍵的一行代碼是哪行?LayoutInflaterCompat.setFactory(layoutInflater, this)
這行代碼是整個換膚流程最關鍵的一行代碼。我們來干預一下所有Activity onCreateView時的布局加載過程。我們在SkinAttrSupport.getSkinAttrs中自己解析了AttributeSet。
/*** 從xml的屬性集合中獲取皮膚相關的屬性。*/fun getSkinAttrs(attrs: AttributeSet, context: Context): MutableList<SkinAttr> {val skinAttrs: MutableList<SkinAttr> = ArrayList()var skinAttr: SkinAttrfor (i in 0 until attrs.attributeCount) {val attrName = attrs.getAttributeName(i)val attrValue = attrs.getAttributeValue(i)val attrType = getSupportAttrType(attrName) ?: continueif (attrValue.startsWith("@")) {val ref = attrValue.substring(1)if (TextUtils.isEqualTo(ref, "null")) {// 跳過@nullcontinue}val id = ref.toInt()// 獲取資源id的實體名稱val entryName = context.resources.getResourceEntryName(id)if (entryName.startsWith(SkinConfig.ATTR_PREFIX)) {skinAttr = SkinAttr(attrType, entryName)skinAttrs.add(skinAttr)}}}return skinAttrs}
我們只干預skin_開頭的資源的加載過程,所以解析得到我們需要的屬性,最后得到SkinAttr的列表返回。
package dora.skin.attrimport android.view.View
import android.widget.ImageView
import android.widget.TextView
import dora.skin.SkinLoader
import dora.skin.SkinManagerenum class SkinAttrType(var attrType: String) {/*** 背景屬性。*/BACKGROUND("background") {override fun apply(view: View, resName: String) {val drawable = loader.getDrawable(resName)if (drawable != null) {view.setBackgroundDrawable(drawable)} else {val color = loader.getColor(resName)view.setBackgroundColor(color)}}},/*** 字體顏色。*/TEXT_COLOR("textColor") {override fun apply(view: View, resName: String) {val colorStateList = loader.getColorStateList(resName) ?: return(view as TextView).setTextColor(colorStateList)}},/*** 圖片資源。*/SRC("src") {override fun apply(view: View, resName: String) {if (view is ImageView) {val drawable = loader.getDrawable(resName) ?: returnview.setImageDrawable(drawable)}}};abstract fun apply(view: View, resName: String)/*** 獲取資源管理器。*/val loader: SkinLoaderget() = SkinManager.getLoader()
}
當前skins框架只定義了幾種主要的換膚屬性,你理解原理后,也可以自己進行擴展,比如RadioButton的button屬性等。
開源項目傳送門
如果你要深入理解完整的換膚流程,請閱讀skins的源代碼,[https://github.com/dora4/dview-skins] 。