android换肤整理

来源这里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

            //不为空的,就是系统那些基本控件,
//因为context可能不一样,这里就每次调用from获取
                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)) {
//刚开始弄错了,我们这里返回的结果是像素,所以下边设置需要第一个参数定义类型为px
                            view.setTextSize(TypedValue.COMPLEX_UNIT_PX,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

其他知识

加载本地apk的class文件办法
在2个apk里都添加一个接口文件,比如

public interface LinkImp

然后插件apk实现这个接口,比如
class xxx implement LlinkLmp

之后宿主apk里如下调用即可拿到插件的class实例了。
pathCache://这个路径需要是当前运行的app能访问的目录。比如cacheDir,或者Android/data/包名下的

            try {
                val path=File(Environment.getExternalStorageDirectory(),"app.apk").absolutePath
                var pathCache=activity!!.cacheDir.absolutePath
                val dexClassLoader=DexClassLoader(path,pathCache, null, activity!!.classLoader);
                   val clz= dexClassLoader.loadClass("com.charliesong.demo0327.Test")
                 val  instance= clz.newInstance()
                if(instance is LinkImp){
                    val add= instance.add(22,33)
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }

和AIDL差不多。

修复问题

  1. 2019-04-15
    修复后的代码
            try {
                reflect(context, attrs)//增加这行反射操作
                view = inflater!!.createView(name, prefix, attrs)
            } catch (e: Exception)

or
下边的 if条件不要了,每次都from一个,这样保证context是新的,建议这种,反射不爽,也不好理解

            if (inflater == null) {
                inflater = LayoutInflater.from(context)
            }

问题描述,如下我布局里了有如下的代码,AppBarLayout 和Toolbar

<com.google.android.material.appbar.AppBarLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/appbar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:fitsSystemWindows="true"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
</com.google.android.material.appbar.AppBarLayout>

测试中发现如下文字和箭头应该是白色的,可显示却是黑色的。那么就应该是主题的问题了,也就是context有问题。


image.png

然后利用反射,打印了下inflater的context

    private fun reflect2(name:String,mContext: Context, attrs: AttributeSet) {
        try {
            val filed = LayoutInflater::class.java.getDeclaredField("mConstructorArgs")
            filed.isAccessible = true;
           val arr= filed.get(inflater) as Array<Any>
            println("reflect2===${name}=======${arr[0]}============${mContext}")
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

打印发现AppBarLayout的context不对劲,正常大家都是ActivityCustom@7ec71c3,可到了AppbarLayout的时候context是那个ContextThemeWrapper

AppBarLayout=====ActivityCustom@7ec71c3============android.view.ContextThemeWrapper@802bb04

最开始想歪了,后来想到了这个context不一样是因为AppBarLayout设置了一个不一样主题,然后我试了下,把这个主题删了,大家context都一样了。不过删了,那箭头标题没法变白了。

android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"

最后
LayoutInflater.from(context) :context一样,返回的就是同一个对象,要么通过反射重新设置一下context,有点麻烦。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容