换肤控件的开发过程
目前市面上有很多应用都是有换肤功能,也有叫切换主题,就是把主色、背景色、图标、字体颜色等资源替换掉。虽然我们的项目是比较严肃的办公系统,但我觉得应该加点活泼的东西,在严肃的工作之余也有一丝乐趣。于是去网上搜索了下大家是如何实现的,就发现我平常关注的洪洋大神很早就写过这么一个换肤框架ChangeSkin。于是在他的思路下用kotlin改写了这个框架。
整体思路是这样的,通过为LayoutInfalter去设置自定义Factory,对加载的View进行分析和提取。
在自定义Factory里面创建各个View,并且分析这些View的属性,是否有需要进行替换资源的属性(就是换肤),把它们记录下来,然后进行换肤操作。下面是自定义Factory的部分实现:
override fun onCreateView(parent: View?, name: String, context: Context?, attrs: AttributeSet?): View? {
var view: View? = null
val skinAttrList = filterSkinAttr(attrs, context)
if (skinAttrList.isEmpty()) {
return null
}
if (context == null || attrs == null) {
return null
}
view = mAppCompatActivity.delegate.createView(parent, name, context, attrs)
if (view == null) {
view = createViewFromTag(context, name, attrs)
}
if (view != null) {
val skinView = SkinView(view!!, skinAttrList)
skinViewsMap.put(view!!, skinView)
skinView.apply()
}
if (view == null ) {
Log.i("oncreateView", "create view is null")
}
return view
}
把需要替换资源的view放入SkinView,然后进行换肤操作,SkinView就是做一件事换肤,把当前这个view里面需要换肤的属性一个个替换下,比如background、textColor等。
data class SkinView(val view:View, val skinAttrList: List<BaseSkinAttr>) {
fun apply() {
skinAttrList.map { skinAttr ->
skinAttr.apply(view)
}
}
}
因为需要替换的资源千奇百怪,也有自定义的,不好弄,就写了个BaseSkinAttr
open abstract class BaseSkinAttr(var attrName:String, var originResId: Int, var resName: String) {
abstract fun apply(view: View)
abstract fun copy(attrName:String, originResId: Int, resName: String): BaseSkinAttr
override fun toString(): String {
return "SkinAttr{attrName:$attrName, originResId:$originResId, resName:$resName }"
}
}
在apply方法中进行各个属性自己实现替换资源的方式,因为平常最多的是background、src、textColor这几个属性。
class BackgroundSkinAttr(attrName:String = "", originResId: Int = 0, resName: String = "" ): BaseSkinAttr(attrName, originResId, resName) {
override fun apply(view: View) {
val bottom = view.paddingBottom
val top = view.paddingTop
val right = view.paddingRight
val left = view.paddingLeft
/**
* 资源是color的时候 通过getDrawable获取到drawable 去setBackground 会有颜色获取不正确的情况 ColorDrawable 里面的mBaseColor 和 mUseColor两个值不一致
* 所以如果是color资源,通过getColor获取color值 setBackgroundColor
*/
val isColorId = FancySkinManager.instance().getResourceManager()?.getColorResIdByName(resName) ?: 0
if (isColorId!=0) {
val color = FancySkinManager.instance().getResourceManager()?.getColorByResId(isColorId) ?: -1
if (color != -1){
view.setBackgroundColor(color)
}
}else {
val drawable = FancySkinManager.instance().getResourceManager()?.getDrawable(originResId, resName)
if (drawable!=null) {
view.background = drawable
}
}
view.setPadding(left, top, right, bottom)
}
override fun copy(attrName: String, originResId: Int, resName: String): BaseSkinAttr {
return BackgroundSkinAttr(attrName, originResId, resName)
}
}
这是background属性换肤的实现,里面特别提到了,如果是背景是用颜色的,要专门提取颜色值然后进行view.setBackgroundColor(color)
, 用Drawable对象进行view.background = drawable
设置经常会无效。还没搞明白是为啥。
这样从创建View到设置View需要替换皮肤资源的属性进行各自设置的整个过程就完成了。然后换肤的还有一个关键点,这些换肤的资源怎么获取。就是上面属性apply方法中的FancySkinManager.instance().getResourceManager()?.getDrawable()
这些代码。它获取的怎么就是我要的皮肤资源。
内部资源获取的思路就是加后缀。ResourceManager里面获取资源的实现:
private fun appendSuffix(name: String): String {
var mName = name
if (!TextUtils.isEmpty(mSkinSuffix)) {
mName = "${mName}_$mSkinSuffix"
}
return mName
}
fun getColor(originResId: Int, name: String): Int {
try {
val originColor = ContextCompat.getColor(mContext, originResId)
val trueName = appendSuffix(name)
val id = mResources.getIdentifier(trueName, DEFTYPE_COLOR, mSkinPackageName)
if (id != 0) {
return mResources.getColor(id)
}
return originColor
} catch (e: Resources.NotFoundException) {
return -1
}
}
这是获取颜色的实现,获取Drawable的也是类似的。先给资源名称添加后缀,然后根据新的名称找对于的资源的id,然后根据id获取资源。
FancySkinManager会把当前皮肤的后缀保存起来,这样每次打开这个页面的时候看到的就是换肤后的资源样式了。
/**
* 应用内部换肤
* @param suffix 资源后缀
*/
fun changeSkinInner(suffix: String) {
//清理当前的插件皮肤
cleanPluginSkin()
mCurrentSkinSuffix = suffix
PreferenceUtil.putPluginSuffix(mContext, suffix)
mResourceManager = ResourceManager(mContext, mContext.resources!!, mContext.packageName!!, mCurrentSkinSuffix)
notifySkinChanged()
}
上面是应用内换肤的整个过程。还有一块是使用应用外部的皮肤资源进行换肤。其它过程都是一样的主要是怎么样把资源加载进来,并且根据这些加载的资源创建ResourcesManager。
其实外部资源就是一个apk包,没有代码只有资源,这时候需要加载这个apk包:
/**
* 加载皮肤包
* @param prefSkinPath 皮肤包存放路径
* @param prefSkinPackageName 皮肤包的packageName
* @param suffix 皮肤包内的资源是否需要后后缀
*/
private fun loadSkinPlugin(prefSkinPath: String, prefSkinPackageName: String, suffix: String) {
val assetManager = AssetManager::class.java.newInstance()
val addAssetPath = assetManager.javaClass.getMethod("addAssetPath", String::class.java)
addAssetPath.invoke(assetManager, prefSkinPath)
val superRes = mContext.resources
mResources = Resources(assetManager, superRes?.displayMetrics, superRes?.configuration)
mResourceManager = ResourceManager(mContext, mResources!!, prefSkinPackageName, suffix)
usePlugin = true
}
就是利用AssetManager 加载apk的资源,然后创建一个新的Resources,并用这个新的Resources创建一个新的ResourcesManager,用于我们后面换肤的地方获取皮肤资源。这样获取到的资源就是加载进来的新的皮肤资源了。
切换外部皮肤:
/**
* 使用外部皮肤资源插件包换肤
* @param skinPath 插件地址,apk地址
* @param skinPackageName 插件包名
* @param suffix 资源后缀,默认为空
*/
fun changeSkin(skinPath: String, skinPackageName: String, suffix: String = "", callback: PluginSkinChangingListener = DefaultPluginSkinChangingListener()) {
callback.onStart()
checkPluginParamsThrow(skinPath, skinPackageName)
if (skinPath == mCurrentSkinPath && skinPackageName == mCurrentSkinPackageName) {
return
}
/**
* 后台线程加载 apk皮肤资源插件包
*/
doAsync {
try {
loadSkinPlugin(skinPath, skinPackageName, suffix)
}catch (e: Exception) {
callback.onError(e)
}
uiThread {
try {
updatePluginInfo(skinPath, skinPackageName, suffix)
notifySkinChanged()
callback.onCompleted()
}catch (e: Exception) {
callback.onError(e)
}
}
}
}
具体代码都放在了Github上了FancyChangeSkin](https://github.com/fancylou/FancyChangeSkin)
全部的换肤过程就完了,使用的话,就是把需要换肤的Activity都继承我们的FancySkinActivity, 在我们自己的Application中onCreate方法中初始化我们的FancySkinManager实例FancySkinManager.instance().init(this)
最后更新下,因为需要每个Activity都要继承我们的FancySkinActivity比较麻烦,后来看到有人利用了Application.ActivityLifecycleCallbacks接口来实现,这样就不要每个Activity都继承我们的FancySkinActivity。
class FancySkinActivityLifeCycle : Application.ActivityLifecycleCallbacks {
private val inflaterFactoryMap: WeakHashMap<Context, FancySkinLayoutInflaterFactory> = WeakHashMap()
private constructor(application: Application) {
application.registerActivityLifecycleCallbacks(this)
installLayoutFactory(application)
}
companion object {
private var INSTANCE: FancySkinActivityLifeCycle? = null
fun instance(application: Application): FancySkinActivityLifeCycle {
if (INSTANCE==null) {
INSTANCE = FancySkinActivityLifeCycle(application)
}
return INSTANCE!!
}
}
override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) {
if (activity !=null ) {
installLayoutFactory(activity)
changeStatusColor(activity)
FancySkinManager.instance().addSkinChangedListener(activity, object : SkinChangedListener{
override fun onSkinChanged() {
getSkinLayoutInflaterFactory(activity).applySkin()
changeStatusColor(activity)
}
})
}
}
...
override fun onActivitySaveInstanceState(activity: Activity?, bundle: Bundle?) {
}
override fun onActivityDestroyed(activity: Activity?) {
if (activity!=null) {
getSkinLayoutInflaterFactory(activity).clean()
FancySkinManager.instance().removeSkinChangedListener(activity)
}
}
private fun installLayoutFactory(context: Context) {
val layoutInflater = LayoutInflater.from(context)
try {
val field = LayoutInflater::class.java.getDeclaredField("mFactorySet")
field.isAccessible = true
field.setBoolean(layoutInflater, false)
LayoutInflaterCompat.setFactory(layoutInflater, getSkinLayoutInflaterFactory(context))
} catch (e: NoSuchFieldException) {
e.printStackTrace()
} catch (e: IllegalArgumentException) {
e.printStackTrace()
} catch (e: IllegalAccessException) {
e.printStackTrace()
}
}
....
}
FancySkinManager:
fun withoutActivity(application: Application): FancySkinManager {
init(application)
FancySkinActivityLifeCycle.instance(application)
return instance()
}
然后我们自己应用的Application的onCreate方法中就要这样初始化了
FancySkinManager.instance().withoutActivity(this)