Android应用换肤实现原理解密
App换肤在很多大厂的应用中都是比较常见的;比如:网易云音乐、QQ音乐、酷狗音乐等应用中都是可以见到的。这些应用是如何做到换肤的效果的,接下来就分析一下是如何实现的。
1.控件采集
要实现换肤肯定是要获取到需要换肤的控件;比如ImageView、TextView等这些控件得提前去把它们收集起来在用户点击换肤的时候就批量的找到对应的资源替换的一个过程;控件是如何做到集中采集的呢?这个就需要去看View的加载过程了,这里就不去分析了,在LayoutInflater中的createViewFromTag()中可以找到每一个View都是通过mFactory2.onCreateView();的方法创建的;那么我们需要得到每一个View则需要自已去创建这些View,则要去自己实现这个OnCreateView方法来替换掉系统用自已实现的;具体代码实现如下:
/**
* Create by Wayne on 2020/3/7
* Describe:View的控件采集
*/
public class SkinLayoutInflaterFactory implements LayoutInflater.Factory2 {
private static final String TAG = SkinLayoutInflaterFactory.class.getName();
//记录对应View的构造函数
private static final Map<String,Constructor<? extends View>> mConstructorMap = new HashMap<>();
private static final Class<?>[] mConstructorSignature = new Class[]{Context.class,AttributeSet.class};
private Activity activity;
private SkinAttribute skinAttribute = null;
private static final String [] mClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
public SkinLayoutInflaterFactory(Activity activity, Typeface typeface) {
this.activity = activity;
skinAttribute = new SkinAttribute(typeface);
}
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attributeSet) {
View view = createViewFormTag(name,context,attributeSet);
if(view == null){
view = createView(name,context,attributeSet);
}
if(view != null){
skinAttribute.load(view,attributeSet,activity);
}
return view;
}
@Nullable
@Override
public View onCreateView(@NonNull String s, @NonNull Context context, @NonNull AttributeSet attributeSet) {
return null;
}
private View createViewFormTag(String name,Context context,AttributeSet attrs){
if(-1 != name.indexOf('.')){
return null;
}
for (int i = 0; i < mClassPrefixList.length; i++) {
return createView(mClassPrefixList[i]+name,context,attrs);
}
return null;
}
private View createView(String name,Context context,AttributeSet attrs){
Constructor<? extends View> constructor = findConstructor(context,name);
try {
return constructor.newInstance(context,attrs);
}catch (Exception e){
e.printStackTrace();
}
return null;
}
private Constructor<? extends View> findConstructor(Context context, String name) {
Constructor<? extends View> constructor = mConstructorMap.get(name);
if(null == constructor){
try {
Class<? extends View> clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
constructor = clazz.getConstructor(mConstructorSignature);
mConstructorMap.put(name,constructor);
}catch (Exception e){
e.printStackTrace();
}
}
return constructor;
}
}
接下来就需要替换掉系统中Factory2的对象,需要用自已实现的类以达到控件采集的目的,在类写好后可以调用LayoytInflaterCompat中的setFactory2();方法把我们写好的类设置进去替换掉系统的。
SkinLayoutInflaterFactory skinLayoutInflaterFactory = new SkinLayoutInflaterFactory(activity,typeface);
LayoutInflaterCompat.setFactory2(layoutInflater,skinLayoutInflaterFactory);
所有的控件已经采集到了;则需要去分析出哪一些控件中包含需要换肤的属性保存起来,比如:background、src、textColor等属性都需要换肤;具体要采集的控件属性如下:
private static final List<String> mAttributes = new ArrayList<>();
static {
mAttributes.add("background");
mAttributes.add("src");
mAttributes.add("textColor");
mAttributes.add("drawableLeft");
mAttributes.add("drawableTop");
mAttributes.add("drawableRight");
mAttributes.add("drawableBottom");
mAttributes.add("progressDrawable");
mAttributes.add("thumb");
mAttributes.add("style");
mAttributes.add("track");
mAttributes.add("button");
mAttributes.add("skinTypeface");
}
每一个控件创建完成后都去和这个集合中的属性对比,如果有匹配则把该View和对应的属性保存起来;代码实现如下:
//用于记录换肤需要操作的View与属性信息
private List<SkinView> mSkinViews = new ArrayList<>();
public void load(View view, AttributeSet attrs, Activity activity){
this.mActivity = activity;
List<SkinPair> mSkinPars = new ArrayList<>();
for (int i = 0;i<attrs.getAttributeCount();i++) {
//获得属性名
String attributeName = attrs.getAttributeName(i);
Log.d(TAG,"属性名为:"+attributeName);
if(mAttributes.contains(attributeName)){
String attributeValue = attrs.getAttributeValue(i);
//如果是Color的 以#开头的表示写死的颜色不需要换肤
if(attributeValue.startsWith("#")){
continue;
}
int resId = 0;
if(attributeValue.startsWith("?")){
int attrId = Integer.parseInt(attributeValue.substring(1));
resId = SkinUtils.getResId(view.getContext(),new int[]{attrId})[0];
String typeName = view.getResources().getResourceTypeName(resId);
SkinPair skinPair = new SkinPair(attributeName,typeName,mActivity.getComponentName().toShortString(),resId);
if(!mSkinPars.contains(skinPair)){
mSkinPars.add(skinPair);
}
}
if(attributeName.startsWith("@") && attributeValue.startsWith("@0")){
//正常是以 @ 开头,但是有可能会有以@0开头的,比如没有写id的,就会以@0开头的,所以得过虑掉这些
resId = Integer.parseInt(attributeValue.substring(1));
String typeName = view.getResources().getResourceTypeName(resId);
SkinPair skinPair = new SkinPair(attributeName,typeName,mActivity.getComponentName().toShortString(),resId);
if(!mSkinPars.contains(skinPair)){
mSkinPars.add(skinPair);
}
}
}
}
if(!mSkinPars.isEmpty()){
SkinView skinView = new SkinView(view,mSkinPars,activity.getComponentName().toShortString());
skinView.applySkin(typeface);
mSkinViews.add(skinView);
}else if(view instanceof TextView || view instanceof SkinViewSupport){
//没有属性满足但是需要修改字体
SkinView skinView = new SkinView(view,mSkinPars,activity.getComponentName().toShortString());
skinView.applySkin(typeface);
mSkinViews.add(skinView);
}
}
2.皮肤包加载
上面已经把所有的View已经采集完了,那么接下来就需要把皮肤包加载进来这样才能去获取皮肤包中的资源,Android中的资源管理都是通过AssetManager来管理的,那么则需要去创建AssetManager的实例,注意:这里不能用App中的AssetManager,因为皮肤包是从SD卡加载进来的,不是原App中的,所以不能使用,需要重新创建AssetManager的实例才可以,通过反射去创建AssetManager的实例;代码实现:
/**
* 皮肤包加载
* @param skinPath 皮肤路径 如果为空则使用默认皮肤
*/
public void loadSkin(String skinPath){
if(TextUtils.isEmpty(skinPath)){
SkinResource.getInstance().reset();
//清空资源管理器 皮肤资源属性
SkinResource.getInstance().reset();
}else {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath",String.class);
addAssetPath.invoke(addAssetPath,skinPath);
Resources appResource = mContext.getResources();
Resources skinResource = new Resources(assetManager,appResource.getDisplayMetrics(),appResource.getConfiguration());
SkinPreference.getInstance().setSkinPath(skinPath);
PackageManager mPm = mContext.getPackageManager();
PackageInfo info = mPm.getPackageArchiveInfo(skinPath,PackageManager.GET_ACTIVITIES);
mSkinPackageName = info.packageName;
SkinResource.getInstance().applySkin(skinResource,mSkinPackageName);
}catch (Exception e){
onLoadSkinFailure(e);
e.printStackTrace();
}
}
setChanged();
notifyObservers(null);
}
皮肤包加载进来后就可以实例Resource对象了,这样就可以拿到皮肤包的Resource对象就可以拿到皮肤中的资源文件;当换肤时则需要用皮肤包的Resource去取对应的资源。
3.控件的资源替换
上面控件已经采集,皮肤包也已经加载后内存后则就可以把需要换肤的控件进行皮肤替换,代码实现:
public void applySkin(Typeface typeface) {
applyTypeFace(typeface);
applySkinSupport();
for (SkinPair skinPair : skinPairs) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
Object background = SkinResource.getInstance().getBackground(skinPair
.resId);
if(skinPair.typeName.equals("color")){
view.setBackgroundColor(SkinResource.getInstance().getColor(skinPair.resId));
}else if(skinPair.typeName.equals("drawable") || skinPair.typeName.equals("mipmap")){
if (background instanceof Integer) {
view.setBackgroundColor((int) background);
} else {
view.setBackgroundDrawable((Drawable) background);
}
}
break;
case "src":
background = SkinResource.getInstance().getBackground(skinPair
.resId);
if (skinPair.typeName.equals("drawable") || skinPair.typeName.equals("mipmap")) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
if (view instanceof ImageButton) {
ImageButton imageButton = (ImageButton) view;
if (background instanceof Integer) {
imageButton.setImageDrawable(new ColorDrawable((Integer) background));
} else {
imageButton.setImageDrawable((Drawable) background);
}
} else if (view instanceof EditText) {
EditText editText = (EditText) view;
if (background instanceof Integer) {
editText.setBackground(new ColorDrawable((Integer) background));
} else {
editText.setBackground((Drawable) background);
}
} else if (view instanceof ImageView) {
ImageView imageView = (ImageView) view;
if (background instanceof Integer) {
imageView.setImageDrawable(new ColorDrawable((Integer) background));
} else {
imageView.setImageDrawable((Drawable) background);
}
} else {
if (background instanceof Integer) {
view.setBackground(new ColorDrawable((Integer) background));
} else {
view.setBackground((Drawable) background);
}
}
}
}
break;
case "textColor":
if (view instanceof TextView) {
((TextView) view).setTextColor(SkinResource.getInstance().getColorStateList
(skinPair.resId));
} else if (view instanceof Button) {
((Button) view).setTextColor(SkinResource.getInstance().getColorStateList
(skinPair.resId));
}
break;
}
}
根据具体的View判断来进行资源替换完成换肤操作;以上就是换肤三个步骤;换肤分析的是总理思路,还有一细节处理这里就大概描述一下,如果要完善还要考虑内存泄露的问题,因为需要换肤的View是保存起来的,所以当Activity在onDestroyed的时候则需要把Activity中的View去释放掉,这里就涉及Activity的生命周期管理了,这样可以去实现 Application中的ActivityLifecycleCallbacks去监听Activity的生命周期;还有就是字体的替换和状态栏的换肤也没有分析,其实这两个很简单字体的替换也是加载皮肤包中字体出来然后TextView动态设置字体文件就可以达到字体替换效果,状态栏的换肤可以通过Activity拿到Window去替换颜色就可以了。以上就是换肤的全部内容,如果发现哪里描述有问题或者是错误可以私信指正,毕竟本人知识水平有限难免出错;需要demo也可以联系我,如果文章让你get到干货请给我一个赞吧。