市场上很多app支持换肤效果,并且还可以从网上下载皮肤包进行加载换肤,接下来就来聊一聊它的实现原理。
思路:首先我们需要知道哪些控件需要实现换肤,有两种方法
第一种:自己整理,通过findViewById一个个实例化出需要执行换肤的控件,在拿到颜色值,或图片后一个个去替换。
第二种:在布局文件初始化的时候通过属性判断去找出需要换肤的控件。
很明显第一种比较麻烦,而且不易维护。
那么今天就看一下第二种,首先要从setContentView下手,看看它里面到底做了什么事情。
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}
继续看getDelegate()
/**
* @return The {@link AppCompatDelegate} being used by this Activity.
*/
@NonNull
public AppCompatDelegate getDelegate() {
if (mDelegate == null) {
mDelegate = AppCompatDelegate.create(this, this);
}
return mDelegate;
}
最后查看到setContentView的具体实现:
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mOriginalWindowCallback.onContentChanged();
}
是不是很熟悉 LayoutInflater.from(mContext).inflate(resId, contentParent);
继续看inflate(resId, contentParent);
里面调用了
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
点进去发现是调用了Factory的 onCreateView方法
/**
* This routine is responsible for creating the correct subclass of View
* given the xml element name. Override it to handle custom view objects. If
* you override this in your subclass be sure to call through to
* super.onCreateView(name) for names you do not recognize.
*
* @param name The fully qualified class name of the View to be create.
* @param attrs An AttributeSet of attributes to apply to the View.
*
* @return View The View created.
*/
protected View onCreateView(String name, AttributeSet attrs)
throws ClassNotFoundException {
return createView(name, "android.view.", attrs);
}
具体实现就在createView里了
/**
* Low-level function for instantiating a view by name. This attempts to
* instantiate a view class of the given <var>name</var> found in this
* LayoutInflater's ClassLoader.
*
* <p>
* There are two things that can happen in an error case: either the
* exception describing the error will be thrown, or a null will be
* returned. You must deal with both possibilities -- the former will happen
* the first time createView() is called for a class of a particular name,
* the latter every time there-after for that class name.
*
* @param name The full name of the class to be instantiated.
* @param attrs The XML attributes supplied for this instance.
*
* @return View The newly instantiated view, or null.
*/
public final View createView(String name, String prefix, AttributeSet attrs)
throws ClassNotFoundException, InflateException {
Constructor<? extends View> constructor = sConstructorMap.get(name);
if (constructor != null && !verifyClassLoader(constructor)) {
constructor = null;
sConstructorMap.remove(name);
}
Class<? extends View> clazz = null;
try {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, name);
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
clazz = mContext.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
if (mFilter != null && clazz != null) {
boolean allowed = mFilter.onLoadClass(clazz);
if (!allowed) {
failNotAllowed(name, prefix, attrs);
}
}
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
sConstructorMap.put(name, constructor);
} else {
// If we have a filter, apply it to cached constructor
if (mFilter != null) {
// Have we seen this name before?
Boolean allowedState = mFilterMap.get(name);
if (allowedState == null) {
// New class -- remember whether it is allowed
clazz = mContext.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
boolean allowed = clazz != null && mFilter.onLoadClass(clazz);
mFilterMap.put(name, allowed);
if (!allowed) {
failNotAllowed(name, prefix, attrs);
}
} else if (allowedState.equals(Boolean.FALSE)) {
failNotAllowed(name, prefix, attrs);
}
}
}
Object lastContext = mConstructorArgs[0];
if (mConstructorArgs[0] == null) {
// Fill in the context if not already within inflation.
mConstructorArgs[0] = mContext;
}
Object[] args = mConstructorArgs;
args[1] = attrs;
final View view = constructor.newInstance(args);
if (view instanceof ViewStub) {
// Use the same context when inflating ViewStub later.
final ViewStub viewStub = (ViewStub) view;
viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
}
mConstructorArgs[0] = lastContext;
return view;
} catch (NoSuchMethodException e) {
final InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Error inflating class " + (prefix != null ? (prefix + name) : name), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (ClassCastException e) {
// If loaded class is not a View subclass
final InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Class is not a View " + (prefix != null ? (prefix + name) : name), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} catch (ClassNotFoundException e) {
// If loadClass fails, we should propagate the exception.
throw e;
} catch (Exception e) {
final InflateException ie = new InflateException(
attrs.getPositionDescription() + ": Error inflating class "
+ (clazz == null ? "<unknown>" : clazz.getName()), e);
ie.setStackTrace(EMPTY_STACK_TRACE);
throw ie;
} finally {
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
}
这里可以看出,它是根据控件名称通过反射实例化控件:
Constructor<? extends View> constructor = sConstructorMap.get(name);
clazz = mContext.getClassLoader().loadClass( prefix != null ? (prefix + name) : name).asSubclass(View.class);
final View view = constructor.newInstance(args);
onCreateView是Factory接口的方法,那么我们就可以自定义Factory来实现我们自己的逻辑。
public class SkinFactory implements LayoutInflater.Factory {
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
Log.e("skin", "--> "+name);
return null;
}
}
并在Activity的onCreate方法中把我们自定义的Factory设置进去。
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
skinFactory = new SkinFactory();
LayoutInflater.from(this).setFactory(skinFectory);
super.onCreate(savedInstanceState);
setContentView(R.layout.skin_layout);
}
运行后SkinFactory中的打印信息如下:
仔细看了一下发现正是Activity布局文件中所有控件的名称。
根据这些名称我们就可以实例化出相应的view,但是其中有些名称并不全,如Button,没关系,我们给他补全就行了。
SkinFactory 中新建一个方法:
private static final String[] sClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
private View createView(String name, Context context, AttributeSet attrs) {
View view = null;
//这里用 . 来判断是系统控件还是自定义控件
if (name.contains(".")) {
//自定义控件直接去创建
view = SkinUtils.getView(name, context, attrs);
} else {
//系统控件因为我们也不知道它是属于哪个包下的,所以要通过for循环去拼接三个包并创建view
for (String s : sClassPrefixList) {
String viewPath = s + name;
view = SkinUtils.getView(viewPath, context, attrs);
//如果view不为空则说明包名拼接正确,打断循环
if (view != null) {
break;
}
}
}
return view;
}
获取view的方法:
//根据控件名称通过反射实例化控件
public static View getView(String name, Context context, AttributeSet attributeSet) {
View view = null;
try {
Class<?> tClass = context.getClassLoader().loadClass(name);
Constructor<?> constructor = tClass.getConstructor(new Class[]{Context.class, AttributeSet.class});
view = (View) constructor.newInstance(context, attributeSet);
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
return view;
}
其实就是刚才源码中看到的代码(既然要自定义Factory那么就需要自己去实现view的创建)。
接下来我们就来获取这些控件在xml中定义的各种属性:
private void parseViewAttr(View view, Context context, AttributeSet attrs) {
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//获取属性名称
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
//打印一下这两句获取到的是什么东西
System.out.println(attrName + "-----attrName----------attrValue---------" + attrValue);
//打印结果:
//System.out: orientation -----attrName----------attrValue--------- 1
//System.out: background -----attrName----------attrValue--------- @2131361792
//System.out: textColor -----attrName----------attrValue--------- @2130968615
//.....
//从打印结果可以看出 attrName 为属性名 , attrValue 为属性值
//(如 android:background="@mipmap/ic_launcher" attrName为android:background attrValue为@mipmap/ic_launcher 。
//只有我们自己写的值才会有@符号(如 @2131361792)
//获取一个控件中所有的属性
if (attrValue.startsWith("@")) {
int id = Integer.parseInt(attrValue.substring(1));
//获取资源文件类型如 color drawable
String entryType = context.getResources().getResourceTypeName(id);//相当于color等资源文件类型
String entryName = context.getResources().getResourceEntryName(id);//值
}
}
}
看完上面的代码以及注释后,我们可以知道,我们可以获取到view以及其属性,那么就可以通过设置view属性来实现换肤了。
首先需要保存view及属性:
public class SkinAttr {
//R文件中对应的静态常量(即 @ 2131361792 后面的数字)
public int id;
//属性名(如:background,textColor等)
public String attrName;
//属性类型(如:color,drawable等)
public String attrType;
public SkinAttr(int id, String attrName, String attrType) {
this.id = id;
this.attrName = attrName;
this.attrType = attrType;
}
}
public class SkinItem {
//获取到的view
public View view;
//view对应的属性集合
public List<SkinAttr> skinAttrs;
public SkinItem(View view, List<SkinAttr> skinAttrs) {
this.view = view;
this.skinAttrs = skinAttrs;
}
}
parseViewAttr()方法修改后如下:
private void parseViewAttr(View view, Context context, AttributeSet attrs) {
List<SkinAttr> skinAttrs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
String attrName = attrs.getAttributeName(i);
String attrValue = attrs.getAttributeValue(i);
System.out.println(attrName + "-----attrName----------attrValue---------" + attrValue);
//获取一个控件中所有的属性
if (attrValue.startsWith("@")) {
int id = Integer.parseInt(attrValue.substring(1));
String entryType = context.getResources().getResourceTypeName(id);//相当于color等资源文件类型
String entryName = context.getResources().getResourceEntryName(id);//值
SkinAttr skinAttr = new SkinAttr(id, attrName, entryType);
skinAttrs.add(skinAttr);
}
}
SkinItem skinItem = new SkinItem(view, skinAttrs);
skinItems.add(skinItem);
}
skinItems即获取到的所有view集合。
当我们需要修改属性时只需要操作skinItems就可以了。
完整SkinItem代码:
public class SkinItem {
public View view;
public List<SkinAttr> skinAttrs;
public SkinItem(View view, List<SkinAttr> skinAttrs) {
this.view = view;
this.skinAttrs = skinAttrs;
}
/**
* 开始换肤
*/
public void apply() {
for (int i = 0; i < skinAttrs.size(); i++) {
//这里可以根据自己的意愿去添加相应的case,想改背景就判断background,想改字体颜色就判断textColor。
switch ((skinAttrs.get(i).attrName)) {
case "background":
//背景可以设置颜色和图片
if (skinAttrs.get(i).attrType.equals("drawable")||skinAttrs.get(i).attrType.equals("mipmap")) {
//根据id,类型,属性名获取drawable
view.setBackgroundDrawable(SkinManager.getInstance().getDrawable(skinAttrs.get(i).id,skinAttrs.get(i).attrType));
} else if (skinAttrs.get(i).attrType.equals("color")) {
//根据id,类型,属性名获取color
view.setBackgroundColor(SkinManager.getInstance().getColor(skinAttrs.get(i).id));
}
break;
case "textColor":
if (view instanceof TextView) {
((TextView) view).setTextColor(SkinManager.getInstance().getColor(skinAttrs.get(i).id));
}
break;
}
}
}
}
获取Drawable和color的具体实现:
/**
* 获取颜色资源
*
* @param color
* @return
*/
public int getColor(int color) {
//首先获取本app内的资源
int mycolor = context.getResources().getColor(color);
//resources为插件apk的resources,如果resources不为空说明加载了插件apk
if (resources != null) {
//获取插件apk中的资源
String entryName = context.getResources().getResourceEntryName(color);
int resid = resources.getIdentifier(entryName, "color", packageName);
if (resid > 0) {
mycolor = resources.getColor(resid);
}
}
return mycolor;
}
/**
* 获取图片资源
*
* @param color
* @return
*/
public Drawable getDrawable(int color, String type) {
Drawable mydrawable = context.getResources().getDrawable(color);
if (resources != null) {
//获取插件apk中的资源
String entryName = context.getResources().getResourceEntryName(color);
int resid = resources.getIdentifier(entryName, type, packageName);
if (resid > 0) {
mydrawable = resources.getDrawable(resid);
}
}
return mydrawable;
}
然后就剩最后一步:加载插件apk
想要获取apk资源首先需要获取Resource,因为获取资源时都是getResource().getxxxx, getResource()指定了apk文件的路径
//assetManager用来指定apk文件路径,后面两个参数为设备的屏幕信息,这里不关心它。
Resource resources = new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
而AssetManager构造方法中标有{@hide},说明它是一个隐藏的方法,外界不能通过new来创建。所以只能通过反射来获取该类实例。
//通过反射获得AssetManager
AssetManager assetManager = AssetManager.class.newInstance();
//addAssetPath为添加apk路径的方法
Method method = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
//执行方法
//这里path为插件apk路径
method.invoke(assetManager, path);
完整的加载插件apk方法:
public void getOtherApkSrc(String path) {
try {
//path为空
if (path == null) {
return;
}
//通过反射获得AssetManager
AssetManager assetManager = AssetManager.class.newInstance();
//获取添加apk路径的方法
Method method = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
//执行方法
method.invoke(assetManager, path);
resources = new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
//获取插件apk的包名
packageName = context.getPackageManager().getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES | PackageManager.GET_SERVICES).packageName;
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
//这里获得了resource以及其包名。
测试:在Activity中调用更换皮肤
public void changeColor(View view) {
skinManager = SkinManager.getInstance();
skinManager.init(SkinActivity.this);
String apkPath = SkinUtils.getPath("app-1.apk");
skinManager.getOtherApkSrc(apkPath);
skinFectory.apply();
}
调用前:
调用后
粗略的记录一下,以后用得着了不至于一脸懵逼。。。