android換膚整理
來源這裡 https://www.jianshu.com/p/4c8d46f58c4f
整理下,方便以後使用,剛寫完簡單測試沒啥問題,以後發現問題再修改
前言
核心思路就是用到這個方法
這個出來很久了,我只記得幾年前用的時候就簡單的修改頁面字型的大小
LayoutInflaterCompat.setFactory2(layoutInflater, object : LayoutInflater.Factory2
換膚的方法
-
如果只是簡單的,固定的,那麼其實本地寫幾套主題就可以實現了
也就是這種,佈局裡使用 ?attr/主題裡的欄位
?attr/colorPrimary
然後不同的主題指定不同的顏色,圖片,大小就行了
<item name="colorPrimary">@color/colorPrimary</item>
-
就是根據開頭帖子的內容,載入一個本地的apk檔案,獲取到他的resource
然後利用下邊的方法獲取到資源,這種打包成apk的方便網路下載,可以隨時新增面板
mOutResource?.getIdentifier(resName, type, mOutPkgName)
工具類
本工具類使用到了LiveData,方便通知其他頁面重新整理,並且是用kt寫的
-
LiveDataUtil
根據一個string的key值,儲存相關的LiveData,完事獲取LiveData也是通過這個key值。
換膚操作裡主要用了最後兩個方法,
getResourcesLiveData 獲取LiveData<Resources>
observerResourceChange:註冊觀察者
import android.arch.lifecycle.LifecycleOwner import android.arch.lifecycle.MutableLiveData import android.arch.lifecycle.Observer import android.content.res.Resources object LiveDataUtil { private val bus = HashMap<String, MutableLiveData<Any>>() fun <T> with(key: String, type: Class<T>): MyLiveData<T> { if (!bus.containsKey(key)) { bus[key] = MyLiveData(key) println("create new============$key") } return bus[key] as MyLiveData<T> } fun with(key: String): MyLiveData<Any> { return with(key, Any::class.java) } fun observer(key: String,lifecycleOwner: LifecycleOwner,observer: Observer<Any>){ with(key).observe(lifecycleOwner,observer) } fun <T> observer(key: String,type:Class<T>,lifecycleOwner: LifecycleOwner,observer: Observer<T>){ with(key,type).observe(lifecycleOwner,observer) } funremove(key:String,observer: Observer<Any>){ if(bus.containsKey(key)){ bus[key]?.removeObserver(observer) } } fun clearBus(){ bus.keys.forEach { bus.remove(it) } } class MyLiveData<T> (var key:String):MutableLiveData<T>(){ override fun removeObserver(observer: Observer<T>) { super.removeObserver(observer) if(!hasObservers()){ bus.remove(key)//多個頁面添加了觀察者,一個頁面銷燬這個livedata還需要的,除非所有的觀察者都沒了 ,才清除這個。 } println("remove===========$key=====${hasObservers()}") } } fun getResourcesLiveData():MutableLiveData<Resources>{ returnwith(SkinLoadUtil.resourceKey,Resources::class.java) } funobserverResourceChange(lifecycleOwner: LifecycleOwner,observer: Observer<Resources>){ getResourcesLiveData().observe(lifecycleOwner,observer) } }
-
SkinLoadUtil
根據傳入的apk的sdcard路徑,通過反射獲取這個apk的assetManager,進而生成對應的resource
拿到resource也就可以拿到這個apk的資原始檔了
public int getIdentifier(String name, String defType, String defPackage)
import android.content.Context import android.graphics.drawable.Drawable import android.content.res.AssetManager import android.content.pm.PackageManager import android.content.res.Resources import java.io.File class SkinLoadUtil private constructor() { lateinit var mContext: Context companion object { val instance = SkinLoadUtil() val resourceKey = "resourceKey" } fun init(context: Context) { this.mContext = context.applicationContext } private var mOutPkgName: String? = null// TODO: 外部資源包的packageName private var mOutResource: Resources? = null// TODO: 資源管理器 fun getResources(): Resources? { return mOutResource } fun load(path: String) {//path 是apk在sdcard的路徑 val file = File(path) if (!file.exists()) { return } //取得PackageManager引用 val mPm = mContext.getPackageManager() //“檢索在包歸檔檔案中定義的應用程式包的總體資訊”,說人話,外界傳入了一個apk的檔案路徑,這個方法,拿到這個apk的包資訊,這個包資訊包含什麼? val mInfo = mPm.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES) try { mOutPkgName = mInfo.packageName//先把包名存起來 val assetManager: AssetManager//資源管理器 //TODO: 關鍵技術點3 通過反射獲取AssetManager 用來載入外面的資源包 assetManager = AssetManager::class.java.newInstance()//反射建立AssetManager物件,為何要反射?使用反射,是因為他這個類內部的addAssetPath方法是hide狀態 //addAssetPath方法可以載入外部的資源包 val addAssetPath = assetManager.javaClass.getMethod("addAssetPath", String::class.java)//為什麼要反射執行這個方法?因為它是hide的,不直接對外開放,只能反射呼叫 addAssetPath.invoke(assetManager, path)//反射執行方法 mOutResource = Resources(assetManager, //引數1,資源管理器 mContext.getResources().getDisplayMetrics(), //這個好像是螢幕引數 mContext.getResources().getConfiguration())//資源配置//最終創建出一個 "外部資源包"mOutResource ,它的存在,就是要讓我們的app有能力載入外部的資原始檔 LiveDataUtil.getResourcesLiveData().postValue(mOutResource) } catch (e: Exception) { e.printStackTrace() } } //清楚載入的面板,替換為當前apk的resource,這裡的context使用Application的 fun clearSkin(context: Context) { mOutResource = context.resources mOutPkgName = context.packageName LiveDataUtil.getResourcesLiveData().postValue(mOutResource ) } fun getResId(resName: String, type: String): Int { return mOutResource?.getIdentifier(resName, type, mOutPkgName) ?: 0 } //type 有可能是mipmap fun getDrawable(resName: String, type: String = "drawable"): Drawable? { val res = getResId(resName, type) if (res > 0) { return mOutResource?.getDrawable(res); } return null; } fun getColor(resName: String): Int { val res = getResId(resName, "color") if (res <= 0) { return -1 } return mOutResource?.getColor(res) ?: -1 } fun getDimen(resName: String, original: Int): Int { val res = getResId(resName, "dimen") if (res <= 0) { return original } return mOutResource?.getDimensionPixelSize(res) ?: original } fun getString(resName: String): String? { val res = getResId(resName, "string") if (res <= 0) { return null } return mOutResource?.getString(res) } }
- CustomFactory
import android.content.Context import android.content.res.Resources import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.support.v7.app.AppCompatDelegate import android.text.TextUtils import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.ImageView import android.widget.TextView import java.util.* class CustomFactory(var delegate: AppCompatDelegate) : LayoutInflater.Factory2 { private var mOutResource: Resources? = null// TODO: 資源管理器 fun resourceChange(resources: Resources?) { mOutResource = resources loadSkin() } private var inflater: LayoutInflater? = null private var startContent = false;//我們的view都是在系統id為android:id/content的控制元件裡的,所以在這之後才處理。 override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? { if (parent != null && parent.id == android.R.id.content) { startContent = true; } var view = delegate.createView(parent, name, context, attrs); if (!startContent) { return view } if (view == null) { //目前測試兩種情況為空: // 1.自定義的view,系統的或者自己寫的,看xml裡,帶包名的控制元件 //2. 容器類元件,繼承ViewGroup的,比如LinearLayout,RadioGroup,ScrollView,WebView //不為空的,就是系統那些基本控制元件, if (inflater == null) { inflater = LayoutInflater.from(context) } val index = name.indexOf(".") var prefix = "" if (index == -1) { if (TextUtils.equals("WebView", name)) { prefix = "android.webkit." } else { prefix = "android.widget." } } try { view = inflater!!.createView(name, prefix, attrs) } catch (e: Exception) { //api26以下createView方法有bug,裡邊用到了一個context是空的,所以這裡進行異常處理,通過反射,重新設定context try { reflect(context, attrs) view = inflater!!.createView(name, prefix, attrs) } catch (e: Exception) { e.printStackTrace() } } } if (view != null && !TextUtils.equals("fragment", name)) { val map = hashMapOf<String, String>() repeat(attrs.attributeCount) { val name = attrs.getAttributeName(it) val value = attrs.getAttributeValue(it) //println("attrs===========$name==${value}") if (value.startsWith("@")) {//我們只處理@開頭的資原始檔 map.put(name, value) } mOutResource?.apply { //切換面板以後,部分ui才開始載入,這時候就要用新的resource來載入了 handleKeyValue(view, name, value) } } views.put(view, map) } println("$name==========$view") return view; } private fun reflect(mContext: Context, attrs: AttributeSet) { try { var filed = LayoutInflater::class.java.getDeclaredField("mConstructorArgs") filed.isAccessible = true; filed.set(inflater, arrayOf(mContext, attrs)) } catch (e: Exception) { e.printStackTrace() } } override fun onCreateView(name: String?, context: Context?, attrs: AttributeSet?): View? { return null } val views = hashMapOf<View, HashMap<String, String>>() private fun handleKeyValue(view: View, key: String, value: String) { if (value.startsWith("@")) { var valueInt = 0 try { valueInt = value.substring(1).toInt() } catch (e: Exception) { //處理@style/xxxx這種,型別轉換就錯了,我們也不需要處理這種。 } if (valueInt <= 0) { return } val type = view.resources.getResourceTypeName(valueInt) //type:資源型別,也可以說是res下的那些目錄表示的,drawable,mipmap,color,layout,string val resName = view.resources.getResourceEntryName(valueInt) //resName: xxxx.png ,那麼那麼就是xxxx, string,color,就是資原始檔裡item裡的name //println("key/value===$key / $value=====type;$type====${resName}") //下邊這個處理下background屬性,src(ImageView用的),可以是color,也可以是圖片drawable或mipmap when (type) { "drawable", "mipmap" -> { when (key) { "background" -> { getDrawable(resName, type) { view.background = it } } "src" -> { if (view is ImageView) { getDrawable(resName, type) { view.setImageDrawable(it) } } } } } "color" -> { when (key) { "background" -> { getColor(resName) { view.setBackgroundColor(it) } } "src" -> { if (view is ImageView) { getColor(resName) { view.setImageDrawable(ColorDrawable(it)) } } } } } } //處理下TextView的字型顏色,大小,文字內容,有啥別的可以繼續新增 if (view is TextView) { when (key) { "textColor" -> { getColor(resName) { view.setTextColor(it) } } "textSize" -> { getDimen(resName, view.resources.getDimensionPixelSize(valueInt)) { view.setTextSize(it.toFloat()) } } "text" -> { getString(resName) { view.text = it } } } } //下邊這2個,二選一即可,一個回撥,一個空的方法,用來處理自己app裡自定義view, //使用回撥就不需要重寫這個類了,不用回撥那就重寫這個類處理handleCustomView方法 customHandleCallback?.invoke(view, key, valueInt, type, resName) handleCustomView(view, key, valueInt, type, resName) } } var customHandleCallback: ((view: View, key: String, valueInt: Int, type: String, resName: String) -> Unit)? = null open fun handleCustomView(view: View, key: String, valueInt: Int, type: String, resName: String) { //這個是app裡自定義的類,簡單處理下。 //if (view is TextViewWithMark) { //if (TextUtils.equals("sage_mark_bg_color", key)) { //getColor(resName) { //view.markBgColor = it //} //} //if (TextUtils.equals("sage_mark_content", key)) { //getString(resName) { //view.markContent = it //} //} //} } fun getDrawable(resName: String, type: String = "drawable", action: (Drawable) -> Unit) { val drawable = SkinLoadUtil.instance.getDrawable(resName, type) drawable?.apply { action(this) } } fun getColor(resName: String, action: (Int) -> Unit) { val c = SkinLoadUtil.instance.getColor(resName) if (c != -1) { action(c) } } fun getDimen(resName: String, original: Int, action: (Int) -> Unit) { val size = SkinLoadUtil.instance.getDimen(resName, original) action(size) } fun getString(resName: String, action: (String) -> Unit) { val str = SkinLoadUtil.instance.getString(resName) str?.apply { action(this) } } fun loadSkin() { println("loadSkin===========${views.size}") views.keys.forEach { val map = views.get(it) ?: return val view = it; map.keys.forEach { val value = map.get(it) println("loadSin:$view==========$it==$value") handleKeyValue(view, it, value!!) } } } }
-
使用
Application的onCreate方法裡新增如下程式碼,初始化context
SkinLoadUtil.instance.init(this)
然後在activity的基類裡新增如下的程式碼
open var registerSkin=true// 決定頁面是否支援換膚 var customFactory:CustomFactory?=null//,如果你要繼承這個類重寫程式碼的話,那這裡改成子類名字即可 override fun onCreate(savedInstanceState: Bundle?) { if(registerSkin){ customFactory= CustomFactory(delegate).apply { resourceChange(SkinLoadUtil.instance.getResources()) //customHandleCallback={view, key, valueInt, type, resName -> //回撥處理自定義的view, //} } LayoutInflaterCompat.setFactory2(layoutInflater,customFactory!!) LiveDataUtil.observerResourceChange(this, Observer { customFactory?.resourceChange(it) }) } super.onCreate(savedInstanceState) }
下邊是點選換膚按鈕的操作
主要就是獲取到apk在sdcard的路徑,傳進來即可,我這裡放在根目錄了,實際中隨意調整。
這種好處是面板可以隨時從伺服器下載下來用。
btn_skin1.setOnClickListener { SkinLoadUtil.instance.load(File(Environment.getExternalStorageDirectory(),"skin1.apk").absolutePath) } btn_skin2.setOnClickListener { SkinLoadUtil.instance.load(File(Environment.getExternalStorageDirectory(),"skin2.apk").absolutePath) } btn_clear.setOnClickListener { //還原為預設的面板,清除已載入的面板 SkinLoadUtil.instance.clearSkin(activity!!.applicationContext) }
-
新建個工程
把不需要的目錄啥都刪了,就留下res下的即可
然後就是新增和宿主app要換的資源,
比如圖片,就弄個同名的放在對應目錄下
比如下邊這裡要改的,修改為新的值就行了
<string name="skin1_show">修改後的</string> <color name="item_index_text_color">#0000ff</color> <dimen name="item_index_title_size">14sp</dimen>
記得把工程style.xml下預設新增的主題都刪了,這樣build.gradle下關聯的庫就可以刪光了。打包出來的apk就只有資原始檔的大小了。
然後點選makeProject

image.png
然後在下圖位置就能拿到apk拉,當然了你要帶簽名打包apk也隨意。

image.png