概述
换肤是指在APP运行期间改变界面的字体、颜色、图片等。换肤的出现是为了满足日常产品和运营需求,满足用户个性化界面定制,提高用户体验等。
换肤方案
1.静态换肤:将图片、颜色、字体等皮肤资源打包进apk,以实现不同的主题样式等换肤需求。
2.动态换肤:由服务器下发皮肤包资源,APP在运行时读取服务器下发的皮肤包完成换肤需求。
动态换肤优势
1.apk大小:动态换肤不需要将皮肤资源打包进apk内,可以有效减少apk的大小
2.灵活性:动态换肤的皮肤包由服务器下发,不需要发版本就可以实现皮肤包的更新,用户也可以有更多的皮肤进行选择
3.维护性:动态换肤的皮肤包模块独立,便于维护
动态换肤的优势明显,所以本文主要是讲解动态换肤的原理。
换肤流程
整个换肤的流程可以概括为三部分:加载皮肤资源、获取需要换肤的控件(View)、替换皮肤。下面分别对每个部分进行解析。
加载皮肤资源
当我们将从服务器下载的皮肤包保存到本地之后,我们需要将皮肤包内的资源加载进app内,然后我们才能使用皮肤包中的资源。Android中资源的加载都是通过AssetManager来完成的,所有我们加载皮肤包也需要用到AssetManager。
public void loadSkin(String path) {
try {
//反射创建AssetManager
AssetManager manager = AssetManager.class.newInstance();
// 资料路径设置 目录或者压缩包
Method addAssetPath = manager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(manager, path);//1
//app Resources对象
Resources appResources = this.application.getResources();
//皮肤包 Resources对象
Resources skinResources = new Resources(manager,
appResources.getDisplayMetrics(), appResources.getConfiguration());//2
//记录
SkinPreference.getInstance().setSkin(path);
//获取外部Apk(皮肤包) 包名
PackageManager packageManager = this.application.getPackageManager();
PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
String packageName = packageArchiveInfo.packageName;
SkinResources.getInstance().applySkin(skinResources,packageName);//3
} catch (Exception e) {
e.printStackTrace();
}
}
通过反射创建出AssetManager对象之后,反射调用AssetManager的addAssetPath方法将皮肤包的资源加载进apk。
注释2处根据刚刚创建的AssetManager对创建出皮肤包对应的Resources对象。在注释3处将皮肤包的Resources对象和皮肤包的包名保存到SkinResources中。至此我们已经将皮肤包资源加载进app中,后续可以通过皮肤包的Resources对象获取资源信息。
获取需要换肤的控件(View)
在换肤之前首先要确定哪些控件(View)是需要换肤的,那么我们如何采集这些需要换肤的控件?
看过源码的应该都清楚,Activity是依靠LayoutInflate来解析xml布局的,我们先来看一下LayoutInflate解析xml布局的核心方法实现:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
。。。
View view;
if (mFactory2 != null) {//1
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {//2 mFactory2为null的情况下view也为null
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
}
createViewFromTag方法负责将xml布局中的控件创建成view对象。正常情况下mFactory2为null,代码会走到注释2处创建view,view创建出来之后就可以填充到activity上显示给用户。
mFactory2是一个Factory2类型的接口,自定义一个类通过实现这个接口重写onCreateView方法我们就可以接管xml布局中View的创建,这样我们就能采集到需要换肤的控件。
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = createViewFromTag(name, context, attrs);
if(null == view){
view = createView(name, context, attrs);
}
//1.筛选符合属性View
skinAttribute.load(view, attrs);
return view;
}
完成xml布局View的创建之后,通过调用SkinAttribute.load方法来采集单个view当中可以用于换肤的属性,比如background、textColor等。SkinAttribute是自定义用于采集控件属性和换肤操作的属性处理类。看一下SkinAttribute.load方法的实现:
public void load(View view, AttributeSet attrs) {
List<SkinPain> skinPains = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//获取属性名字
String attributeName = attrs.getAttributeName(i);
//1.mAttribute是用来存储属性的集合
if (mAttributes.contains(attributeName)) {
//获取属性对应的值
String attributeValue = attrs.getAttributeValue(i);
if (attributeValue.startsWith("#")) {
continue;
}
int resId;
//判断前缀字符串 是否是"?"
//attributeValue = "?2130903043"
if (attributeValue.startsWith("?")) { //系统属性值
//字符串的子字符串 从下标 1 位置开始
int attrId = Integer.parseInt(attributeValue.substring(1));
resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
} else {
//@1234564
resId = Integer.parseInt(attributeValue.substring(1));
}
if (resId != 0) {
SkinPain skinPain = new SkinPain(attributeName, resId);//2
skinPains.add(skinPain);
}
}
}
if (!skinPains.isEmpty() || view instanceof TextView || view instanceof SkinViewSupport) {
SkinView skinView = new SkinView(view, skinPains);//3
skinView.applySkin(mTypeface);
skinViews.add(skinView);//4
}
}
load方法主要做的事是解析出控件(View)所对应属性的属性名和属性值在R文件中对应的id,并将控件与其对应的所有属性绑定在一起,然后将控件(View)保存在一个集合中。
注释1处的mAttribute是用来存储所有符合换肤条件的属性的集合。在xml中定义的每个控件(View)都会有 很多属性,但不是每个属性都需要换肤,比如控件的width和height。为了减少不必要的性能开销我们只对符合条件的属性(background、textColor等)进行操作。
所有的属性经过编译之后都会以int类型的值保存在R文件中,比如textColor对应的颜色值可能会被编译成123456。通过attrs.getAttributeValue()获取到的值会加一个前缀,所有获取到的值可能是@123456、#123456和?123456这几种类型。所以需要根据不同的前缀做出不同处理。
在获取到属性值在R文件中对应的id之后会在注释2处将属性名与id保存在SkinPain类中,并将SkinPain对象保存在skinPains集合当中。注释3是将控件与skinPains(保存所有符合条件的属性和属性值的集合)绑定在SkinView中,然后在注释4处将SkinView保存在集合当中。
替换皮肤
完成前两个步骤之后,我们就可以进行替换皮肤的操作。前面我们已经将所有可以用于换肤的控件(View)和其对应的属性保存了起来,在替换皮肤的时候只需要遍历处所有的控件然后对其符合条件的属性以此替换成皮肤包中的值就可以了。
/**
* 换皮肤
*/
public void applySkin() {
for (SkinView mSkinView : skinViews) {
mSkinView.applySkin(mTypeface);
}
}
前面已经说过,SkinView中保存了控件和其对应的所有符合条件的属性。遍历出SkinView对象后再调用其的applySkin方法。来看一下这个方法的具体实现:
public void applySkin(Typeface typeface) {
for (SkinPain skinPair : skinPains) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
Object background = SkinResources.getInstance().getBackground(
skinPair.resId);//1
//Color
if (background instanceof Integer) {
view.setBackgroundColor((Integer) background);
} else {
ViewCompat.setBackground(view, (Drawable) background);
}
break;
case "src":
background = SkinResources.getInstance().getBackground(skinPair
.resId);
if (background instanceof Integer) {
((ImageView) view).setImageDrawable(new ColorDrawable((Integer)
background));
} else {
((ImageView) view).setImageDrawable((Drawable) background);
}
break;
case "textColor":
((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
(skinPair.resId));
break;
case "drawableLeft":
left = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableTop":
top = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableRight":
right = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableBottom":
bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "skinTypeface" :
applyTypeface(SkinResources.getInstance().getTypeface(skinPair.resId));
break;
default:
break;
}
if (null != left || null != right || null != top || null != bottom) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
bottom);
}
}
}
可以看到,这个方法主要做的事情是遍历出控件的属性,然后根据xml中定义的属性值id取出皮肤包中对应的属性值id,最后再设置到控件(View)上,至此就完成了皮肤的替换。
以注释1处的代码为例子来看一下是如何做到根据xml中定义的属性值id(编译后从R文件中取出的id值)取出皮肤包中对应的属性值id。先看一下SkinResources的getBackground方法做了什么事:
/**
* 可能是Color 也可能是drawable
*
* @return
*/
public Object getBackground(int resId) {
String resourceTypeName = mAppResources.getResourceTypeName(resId);
if (resourceTypeName.equals("color")) {//1
return getColor(resId);
} else {
// drawable
return getDrawable(resId);
}
}
background属性对应的值可能是color也可能是drawable,所有在注释1处需要做出区分。先看一下getColor方法的实现:
public int getColor(int resId) {
int skinId = getIdentifier(resId);//1
//skinId为0说明在皮肤包中没有对应的资源
if (skinId == 0) {
//返回app中定义的属性值
return mAppResources.getColor(resId);
}
//返回皮肤包中的属性值
return mSkinResources.getColor(skinId);
}
核心逻辑是注释1处的getIdentifier方法:
public int getIdentifier(int resId) {
//例子:R.drawable.ic_launcher 获取到的resName: ic_launcher resType: drawable
String resName = mAppResources.getResourceEntryName(resId);//1
String resType = mAppResources.getResourceTypeName(resId);//2
int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);//3
return skinId;
}
注释1处逻辑是根据xml中定义的属性值id(编译后从R文件中取出的id值)来获取xml中定义的属性值的名字(编译之前在xml中定义的名字,比如R.drawable.ic_launcher),注释2处是获取属性值对应的类型,比如R.drawable.ic_launcher对应的类型是drawable。取出resName和resType后,就可以在注释3处从皮肤包的Resources对象中拿到下载的皮肤包中同一个属性资源的id值。由此可以看出,皮肤包中属性资源名称一定要与app中属性资源名称一致。比如需要替换一张图片,在app中图片名称为ic_launcher.png,那么在皮肤包对应文件夹下的图片也要命名为ic_launcher.png才能完成替换。
拿到皮肤包中的属性值id之后,再将id设置到控件上就能完成替换。
皮肤包
皮肤包本质上也是一个apk文件。但是这个apk不包含任何代码,只包含需要替换的资源。需要注意的是,只有在皮肤包中与宿主app中名字完全一样的资源才能用于替换,否则无效。
总结
本文针对动态换肤的基本原理做出了解析,核心思想就是文中讲解的三个步骤,只要记住这三个步骤就能很容易实现皮肤主题的动态更换