Android知识Android开发安卓技术贴

android换肤整理

2019-03-07  本文已影响5人  有点健忘

来源这里https://www.jianshu.com/p/4c8d46f58c4f
整理下,方便以后使用,刚写完简单测试没啥问题,以后发现问题再修改

前言

核心思路就是用到这个方法
这个出来很久了,我只记得几年前用的时候就简单的修改页面字体的大小

LayoutInflaterCompat.setFactory2(layoutInflater, object : LayoutInflater.Factory2 

换肤的方法

  1. 如果只是简单的,固定的,那么其实本地写几套主题就可以实现了
    也就是这种,布局里使用 ?attr/主题里的字段
?attr/colorPrimary

然后不同的主题指定不同的颜色,图片,大小就行了

<item name="colorPrimary">@color/colorPrimary</item>
  1. 就是根据开头帖子的内容,加载一个本地的apk文件,获取到他的resource
    然后利用下边的方法获取到资源,这种打包成apk的方便网络下载,可以随时添加皮肤
mOutResource?.getIdentifier(resName, type, mOutPkgName)

工具类

本工具类使用到了LiveData,方便通知其他页面刷新,并且是用kt写的

  1. 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)
    }

    fun  remove(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>{
        return  with(SkinLoadUtil.resourceKey,Resources::class.java)
    }
    fun  observerResourceChange(lifecycleOwner: LifecycleOwner,observer: Observer<Resources>){
        getResourcesLiveData().observe(lifecycleOwner,observer)
    }
}
  1. 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)
    }
}
  1. 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!!)
            }
        }
    }


}
  1. 使用
    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)
        }
  1. 新建个工程
    把不需要的目录啥都删了,就留下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
上一篇下一篇

猜你喜欢

热点阅读