Android学习------一教就会的Android换肤实现

1.前言

说到换肤,大家并不陌生,淘宝,京东等App一到节假日就会换上一身新衣服,当然里面还会有一些皮肤提供给你自己下载应用。
换肤和常见的暗黑模式明亮模式有点类似,但是这种模式大多数是通过更改主题的方式来实现的,当我们要实现一些比较复杂的,
比如给某个按钮,文本更换添加背景图片,更改字体字好大小等操作就不是很方便了。今天要将的就是如何实现自定义换肤。

2.需求

产品要求App内部需要根据不同的节假日或者根据24节气App里面的图标背景,字体颜色样式需要有不一样的变化。

2.1 分析需求

根据上面的需求,那么我们先来分析我们应该是怎么一个思路去做,传统的我们修改主题能不能做呢,setTheme()替换掉原来的主题,把每个控件原来的主题样式都修改为新的,按道理来说这样是可以做到的,但是这样有一个什么问题呢,就你每次要添加一个主题的时候你可能需要新发布一个App,把你的主题样式先写进去,然后才能实现,那么如何能够实现,在不更新App的情况下,达到修改主题换肤的功能呢,那就必然是会从网络下载一个主题包或皮肤包,然后根据里面的内容进行替换。

上面我们需求差不多分析好了,总结一下就是,我们换肤是需要

从外部加载资源包,把现有的样式,替换成资源包里面的样式

2.1.1 问题1:

既然是这样,那么我们怎么知道资源包里面有些什么,比如,我们要对一个文本,修改它的颜色和背景,我怎么去找资源包里面的资源,我怎么知道哪一个资源是对我有用的?

我们假设,原始的TextView设置的文字颜色是红色,我们需要修改为黑色,那么我们原始的代码可能是这样


那么我们修改颜色的时候肯定是要拿到这一个控件,然后通过setTextColor来修改它的颜色,那么这个时候我们怎么拿到资源包里面的颜色,并且我们怎么知道哪个颜色是我们需要的,那么这个时候我们就需要定义一个规则,保证2边取的key都是一样的,比如原始颜色名称叫text_color 对应红色 ,那么对应的资源包里面的颜色名称也需要定义为text_color 这样我们就知道 text_color 是我们需要的这一条数据。

所以我们在这里定义颜色的时候就需要声明在colors.xml中,通过定义的名称去取出对应的颜色值。

2.1.2 问题2:皮肤包的制作

这里的皮肤包其实就是一个apk,里面我们不用编写任何代码,我们只需要把对应的资源放入里面就可以了。
比如需要修改的color.xml drawable等文件放入其中。但是需要注意的是,我们需要匹配原始apk包中的资源名称。

2.2 总结

到这里,我们大概已经清楚了该怎么做了。

1.把控件的属性定义在相应的资源文件中,比如颜色,比如大小, 比如其他一些背景图片等。
2.制作一个皮肤包,保持皮肤包里面的资源名称能够对应上原始包的需要替换的资源名称 。
3.完成这上面这2个步骤以后,我们就拿到控件,然后加载资源包里面的资源,然后进行替换。

3.开始编码

3.1 定义资源属性

我们先写个简单的demo,页面样式如下:

Android学习------一教就会的Android换肤实现_第1张图片

我们现在要做的就是修改这些按钮的文本颜色和文本的背景。那么我们的操作步骤是怎么样呢

0.定义好颜色属性
1.加载皮肤包
2.点击换肤,应用皮肤包里面的资源属性
3.点击还原,恢复到初始

现在我们先来做第一步就是先定义好颜色属性,因为这里我们只修改文本的背景和字体颜色,那么我们只需要定义2个颜色值就行了

#000000
#FFFFFF

那么我们在对应的皮肤包里面也定义好对应的属性,只需要把颜色值改一下即可

#00ffee
#008577

现在要做的就是我们需要把皮肤包里面的这几个资源读取出来,设置到我们的现有的控件上。

现在涉及到的一个知识点就是,怎么把皮肤包里面的资源读取出来。

这里再来讲一下,皮肤包实际上就是一个apk文件,当然你可以把他命名为各种名称,apk实际上也是一个压缩文件而已。最后我们通过Android自带的方法把资源提取出来。我们先来看下是怎么做的。

先看下这个方法:

public void loadSkin(String skinPath) {
        AssetManager assetManager = null;
        try {
            assetManager = AssetManager.class.newInstance();

            Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
            addAssetPath.setAccessible(true);
            addAssetPath.invoke(assetManager, skinPath);
            skinSource = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
            //获取到包名
            packageName = context.getPackageManager().getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES).packageName;

        } catch (Exception e) {
            e.printStackTrace();
        }

    }

讲解一下这个方法,skinPath 就是一个皮肤包的路径,因为我们是需要获取Apk包中的资源,那么我们肯定是需要一个Resource,所以这里我们就需要得到皮肤包的Resource,这里就通过反射AssetManager的方法,创建一个Resource。看到这里大家应该有点明白了吧。

1.有一个原始的Resource (context.getResource)
2.有一个皮肤包的Resource (通过AssetManager 创建的一个Resource)

因为只有这样我们才能切换不同的Resource获取不同的皮肤资源。先看下面这一段代码

资源Id=resources.getIdentifier("text_color", "color", packageName);

getIdentifier(String name, String defType, String defPackage)

这段代码大家应该熟悉吧,根据属性名称,类型,和包名,获取到对应的资源id,这里的resource,我们就可以任意切换,因为我们之前已经定义好了原始包中的有一个颜色属性为text_color,皮肤包中有一个颜色属性也为text_color的数据,这样,我们任意切换resource都能获取到对应的值。就能够达到切换皮肤的目的。

看到这里,大家会了吗?

我们再来梳理一下,这个流程是怎样的,我们还有没有需要做的事情。

0.定义好对应的资源属性规则,哪些是需要被替换的
1.创建皮肤包,根据上一步的规则
2.加载皮肤包
3.通过反射AssetManager创建一个皮肤包里面的Resource
4.通过点击一个换肤按钮,然后页面上所有的控件都会发送改变。

这里有一个问题,我们拿到控件,然后再设置他的属性,比如设置颜色,设置背景,那么我们的代码可能是这样(下面写一段伪代码)

Botton btn=findViewById(R.id.btn)

btn.setOnClickListener(){
	
	changeSkin()
}

//换肤
public void changeSkin(){

TextView textView=findViewById(R.id.textView)
TextView textView2=findViewById(R.id.textView)
TextView textView3=findViewById(R.id.textView)
TextView textView4=findViewById(R.id.textView)
TextView textView5=findViewById(R.id.textView)

设置颜色

textView.settextColor()
textView2.settextColor()
textView3.settextColor()
textView4.settextColor()
textView5.settextColor()

设置背景

textView.setBackground()
textView2.setBackground()
textView3.setBackground()
textView4.setBackground()

如果这样做,是不是比较繁琐,有没有这样一个方法,view.changeSkin() ?然后把换肤的操作放到里面去做? 如果想要这样做,那我们肯定就要自定义一个View了对吧。接下来,我们看看如何自定义View? 现有代码里面那么多的控件难道一个一个去替换吗?

3.2 setContentView做了什么?

根据上面的问题,延申到了我们现在的这个问题上面,先来看看下面的这个问题是什么情况?

下面这串代码,我们直接运行一下,看会发送什么



    

我们这里用这样一个代码来打印一个第一个按钮的toStirng

View viewById = findViewById(R.id.btn0);

输出结果

System.out: viewById = android.support.v7.widget.AppCompatButton{1b3496d VFED..C.. ......I. 0,0-0,0 #7f070022 app:id/btn0}

可以看到,打印的并不是 android.widget.Button 而是 android.support.v7.widget.AppCompatButton 这是怎么回事? xml中不是button吗 怎么打印出来是AppCompatButton,这个时候我们就会去看View的创建流程,看看是怎么回事,为什么会返回AppCompatButton。

这里先透露一个类 AppCompatViewInflater , 大家可以打开这个类看一下 其中有这样一段代码

switch (name) {
        case "TextView":
            view = createTextView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "ImageView":
            view = createImageView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "Button":
            view = createButton(context, attrs);
            verifyNotNull(view, name);
            break;
        case "EditText":
            view = createEditText(context, attrs);
            verifyNotNull(view, name);
            break;
        case "Spinner":
            view = createSpinner(context, attrs);
            verifyNotNull(view, name);
            break;
        case "ImageButton":
            view = createImageButton(context, attrs);
            verifyNotNull(view, name);
            break;
        case "CheckBox":
            view = createCheckBox(context, attrs);
            verifyNotNull(view, name);
            break;
        case "RadioButton":
            view = createRadioButton(context, attrs);
            verifyNotNull(view, name);
            break;
        case "CheckedTextView":
            view = createCheckedTextView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "AutoCompleteTextView":
            view = createAutoCompleteTextView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "MultiAutoCompleteTextView":
            view = createMultiAutoCompleteTextView(context, attrs);
            verifyNotNull(view, name);
            break;
        case "RatingBar":
            view = createRatingBar(context, attrs);
            verifyNotNull(view, name);
            break;
        case "SeekBar":
            view = createSeekBar(context, attrs);
            verifyNotNull(view, name);
            break;

	....


@NonNull
protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
    return new AppCompatTextView(context, attrs);
}

@NonNull
protected AppCompatImageView createImageView(Context context, AttributeSet attrs) {
    return new AppCompatImageView(context, attrs);
}

@NonNull
protected AppCompatButton createButton(Context context, AttributeSet attrs) {
    return new AppCompatButton(context, attrs);
}

通过上面的代码可以知道,根据不同的名称都会替换成AppCompat开头的View。又具体是从哪里走到这里来的呢,当然肯定是setContentView里面做的操作,所以我们要看的代码就是在setContentView里面去追踪, 然后看关键代码就是createView,view是如何成xml中生成的。其实就是根据xml的标签,去创建对应的View。(这里不再详细讲了,里面涉及到的知识可以单独拿出来讲了) 先讲如何实现换肤吧。只需要知道,在创建View的时候,Android内部帮我们把View替换了, 这样就不需要我们一个个手动去替换了,那么我们自定义的控件,扩展的TextView,Button等控件,想要换肤,可不可以怎么做呢?都说到这里了,那当然是可以的,那怎么做呢。

这个时候又有一个新的知识点,LayoutInflater.mFactory2,为什么呢,我们来看createView中有一段逻辑。

Android学习------一教就会的Android换肤实现_第2张图片

所以,我们如果这里让这个Factory2不等于null。替换成我们自己的Factory2,是不是就可以了。返回我们包装过的View。
看看Factory2

public interface Factory2 extends Factory {
    /**
     * Version of {@link #onCreateView(String, Context, AttributeSet)}
     * that also supplies the parent that the view created view will be
     * placed in.
     *
     * @param parent The parent that the created view will be placed
     * in; note that this may be null.
     * @param name Tag name to be inflated.
     * @param context The context the view is being created in.
     * @param attrs Inflation attributes as specified in XML file.
     *
     * @return View Newly created view. Return null for the default
     *         behavior.
     */
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
}

我们只需要实现这个onCreateView即可,这个方法里面会返回name 我们可以判断是TextView还是ImageView等

因为这是在setContentView的时候被调用的,那么我们去设置这个Factory2必然是在setContentView之前去设置了。怎么去设置呢,

LayoutInflater里面有一个setFacory2,然后我们可以这样去操作,现在我们先来实操一把,我们就写一个WrapText继承TextView,然后试试看打印的会不会是我们的这个,代码如下

public class WrapTextView extends TextView {
    public WrapTextView(Context context) {
        super(context);
    }

    public WrapTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public WrapTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

}


public class MyFactory2 implements LayoutInflater.Factory2 {
    
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        if(name.equals("TextView")){
            return new WrapTextView(context,attrs);
        }
        return null;
    }

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return null;
    }
}

.

public class MainActivity extends AppCompatActivity {
    MyLayoutInflater myLayoutInflater;

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        LayoutInflaterCompat.setFactory2(LayoutInflater.from(this),new MyFactory2());

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        View viewById = findViewById(R.id.text);


        System.out.println("viewById = " + viewById);
		}
}

输出结果:

I/System.out: viewById = com.xiaxiayige.skindemo.WrapTextView{bb7fad V.ED..... ......ID 0,0-0,0 #7f070083 app:id/text}

可以看到,确实如我们想的那样,我们可以自己根据需要创建自己的View,甚至偷梁换柱,把TextView换成Button,或者换成其他控件。

那么我们就可以在这个自定义的控件里面写这样一个方法

public void changeSkin(Resources resources, String packageName) {

    int textColor = resources.getIdentifier("textColor", "color", packageName);
    int bgColor = resources.getIdentifier("bgColor", "color", packageName);

    ColorStateList colorStateList = resources.getColorStateList(textColor);
    setTextColor(colorStateList);
    setBackgroundColor(resources.getColor(bgColor));

}

在我们需要换肤的时候 直接调用 这个View.changeSkin方法即可。当然为了方便 我们肯定需要顶一个换肤接口,然后循环遍历整个view树是否是实现了整个换肤接口的,如果是,那么我们就调用这个换肤方法就行了。

大家理解到这层意思了吗。 接下来 我们就开始整理完整的代码了。

3.3 实操

根据上面的一些分析,我们再来总结下我们要做的事情。

0.定义好对应的资源属性规则,哪些是需要被替换的
1.创建皮肤包,根据上一步的规则
2.加载皮肤包
3.通过反射AssetManager创建一个皮肤包里面的Resource
4.自定义换肤的View,如TextView,button,等。
5.通过点击一个换肤按钮,然后页面上所有的控件都会发送改变。
6.设置一个自定义的Factory2,返回自己自定义的View。

我们先看效果,再看实现。

Android学习------一教就会的Android换肤实现_第3张图片

上面可以看到 加载了2个不同的皮肤包,最后换肤的效果也是不一样的。

最后来验证上面的代码实现。

3.3.1 定义属性

原始颜色属性

#000000
#FFFFFF

3.3.2 创建皮肤包

新建一个项目,拷贝上面的皮肤属性,修改颜色即可,然后build一个apk即可

#ffffff
#000000

3.3.3 加载皮肤包,获取Resource

这里传入一个路径,然后加载它,创建一个新的Resource,用于获取这个apk里面的资源属性

private void loadSkin(String skinPath) {
    AssetManager assetManager = null;
    try {
        assetManager = AssetManager.class.newInstance();

        Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
        addAssetPath.setAccessible(true);
        addAssetPath.invoke(assetManager, skinPath);
        skinSource = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
        //获取到包名
        packageName = context.getPackageManager().getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES).packageName;

    } catch (Exception e) {
        e.printStackTrace();
    }

}

3.3.4 自定义换肤View

继承自一个TextView,实现一个SkinWidget接口

public class SkinTextView extends AppCompatTextView implements SkinWidget {
    public SkinTextView(Context context) {
        super(context);
    }

    public SkinTextView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public SkinTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
    @Override
    public void changeSkin(Resources resources, String packageName) {
        if (TextUtils.isEmpty(packageName)) {
            packageName = getContext().getPackageName();
            resources=getResources();
        }

        int color = resources.getIdentifier("text_color", "color", packageName);
        int bgColor = resources.getIdentifier("bg_color", "color", packageName);
        ColorStateList colorStateList = resources.getColorStateList(color);
        setTextColor(colorStateList);
        setBackgroundColor(resources.getColor(bgColor));
    }
}

3.3.5 调用换肤方法

换肤

public void changeSkin(ViewGroup viewGroup) {
        int childCount = viewGroup.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childAt = viewGroup.getChildAt(i);
            if (childAt instanceof ViewGroup) {
                changeSkin2((ViewGroup) childAt);
            } else if (childAt instanceof SkinWidget) {
                ((SkinWidget) childAt).changeSkin(skinSource, packageName);
            }
        }

    }

还原到默认

public void reset(ViewGroup viewGroup) {
    int childCount = viewGroup.getChildCount();
    for (int i = 0; i < childCount; i++) {
        View childAt = viewGroup.getChildAt(i);
        if (childAt instanceof ViewGroup) {
            changeSkin1((ViewGroup) childAt);
        } else if (childAt instanceof SkinWidget) {
            ((SkinWidget) childAt).changeSkin(resources, "");
        }
    }

}

3.3.6 重写Factory2接口

因为我们从源码得知,在setContentView内部创建View的过程中,会有一个判断Factory2是否空的逻辑条件,如果不为空,则使用factory2的createView方法来创建View,所以这里我们设置一个自己的factory2接口,最终就会调用我们自己的createView方法,返回我们指定的View。

public class MyFactory2 implements LayoutInflater.Factory2 {

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        if(name.equals("TextView")){
            return new SkinTextView(context,attrs);
        }
        return null;
    }

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return this.onCreateView((View)null, name, context, attrs);
    }
}

在oncreate之前调用setFactory2

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), new MyFactory2());
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
}

4.完结

到了这里,换肤的主要流程就是这样了,根据上面的流程,你自己也可以实现自己的换肤哦,大家也可以根据此功能扩展一下,
哪些控件是可以换肤,哪些控件不允许换肤。最后源码在Github上,欢迎查阅。

Github:SkinDemo

最后

如果不懂为什么要设置factory2的,可以看看setContentView里面的源码,一步步走下去,你就知道了。涉及到的部分源码就没有仔细往下写了,里面内容挺多的,要跟很多源码。不过大家看下面这段源码应该能明白一点把。如果能找到这里,说明你成功了一半。

Android学习------一教就会的Android换肤实现_第4张图片

你可能感兴趣的:(android)