Android 动态换肤原理与实现
概述
本文主要分享类似于酷狗音乐动态换肤效果的实现。
动态换肤的思路:
- 收集换肤控件以及对应的换肤属性
- 加载插件皮肤包
- 替换资源实现换肤效果
- 制作插件皮肤包
收集换肤控件以及对应的换肤属性
换肤实际上进行资源替换,如替换字体、颜色、背景、图片等,对应控件属性有src、textColor、background、drawableLeft等。需要先收集页面控件是否包含换肤属性,那如何收集页面的控件呢?
跟踪LayoutInflater中的createViewFromTag与tryCreateView方法:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
...
try {
View view = tryCreateView(parent, name, context, attrs);
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
...
} catch (ClassNotFoundException e) {
...
} catch (Exception e) {
...
}
}
public final View tryCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context,
@NonNull AttributeSet attrs) {
if (name.equals(TAG_1995)) {
// Let's party like it's 1995!
return new BlinkLayout(context, attrs);
}
View view;
if (mFactory2 != null) {
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);
}
return view;
}
通过源码可知创建控件会先调用Factory2的onCreateView方法,如果返回的View为空才会调用LayoutInflater中的onCreateView与createView,那我们自定一个Factory2就可以用于创建控件并判断是否包含换肤属性了。核心代码如下:
public class SkinLayoutInflateFactory implements LayoutInflater.Factory2, Observer {
static final String mPrefix[] = {
"android.view.",
"android.widget.",
"android.webkit.",
"android.app."
};
//xml中控件的初始化都是调用带Context和AttributeSet这个构造方法进行反射创建的
static final Class<?>[] mConstructorSignature = new Class[]{
Context.class, AttributeSet.class};
//减少相同控件反射的次数
private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
new HashMap<>();
//记录每一个页面需要换肤的控件
private SkinAttribute mSkinAttribute;
/*
* 关系:Activity对应一个LayoutInflate、
* LayoutInflate对一个SkinLayoutInflateFactory
* SkinLayoutInflateFactory对应一个SkinAttribute
*/
private Activity mActivity;
public SkinLayoutInflateFactory(Activity activity) {
this.mActivity = activity;
this.mSkinAttribute = new SkinAttribute();
}
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
View view;
if (-1 == name.indexOf('.')) {//ImageView、TextView等
view = createSdkView(context, name, attrs);
} else {//自定义View、support、AndroidX、第三方控件等
view = createView(context, name, attrs);
}
//关键代码:采集需要换肤的控件
if (view != null) {
mSkinAttribute.look(view, attrs);
}
return view;
}
//以下代码为控件初始化
private View createSdkView(Context context, String name, AttributeSet attrs) {
for (String prefix : mPrefix) {
View view = createView(context, prefix + name, attrs);
if (view != null) {
return view;
}
}
return null;
}
private View createView(Context context, String name, 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 = sConstructorMap.get(name);
if (constructor == null) {
try {
Class<? extends View> clazz = Class.forName(name, false,
context.getClassLoader()).asSubclass(View.class);
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
sConstructorMap.put(name, constructor);
} catch (Exception e) {
e.printStackTrace();
}
}
return constructor;
}
@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
return null;
}
@Override
public void update(Observable o, Object arg) {
//此处进行换肤
mSkinAttribute.applySkin();
}
}
SkinLayoutInflateFactory的主要工作是:
- 创建xml中的控件
- 收集需要换肤的控件
创建控件主要是参考系统源码实现的,重点在于收集换肤控件,通过SkinAttribute记录每一个页面需要换肤的控件,核心代码如下:
public class SkinAttribute {
//需要换肤的属性集合,如背景、颜色、字体等
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");
}
//记录每一个页面需要换肤的控件集合
private List<SkinView> mSkinViewList = new ArrayList<>();
//查找需要换肤的控件以及对应的换肤属性
public void look(View view, AttributeSet attrs) {
List<SkinPair> skinPairList = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
String attributeName = attrs.getAttributeName(i);
if (mAttributes.contains(attributeName)) {
String attributeValue = attrs.getAttributeValue(i);
//如果是写死颜色,则不可换肤
if (attributeValue.startsWith("#")) {
continue;
}
int resId;
//判断是否使用系统资源
if (attributeValue.startsWith("?")) {// ? 系统资源
int attrId = Integer.parseInt(attributeValue.substring(1));
//获取获得Theme中属性中定义的资源id
resId = SkinThemeUtils.getThemeResId(view.getContext(), new int[]{attrId})[0];
} else {//@ 开发者自定义资源
resId = Integer.parseInt(attributeValue.substring(1));
}
SkinPair skinPair = new SkinPair(attributeName, resId);
skinPairList.add(skinPair);
}
}
//如果skinPairList长度不为0,即有换肤属性,此时记录换肤控件
if (!skinPairList.isEmpty() || view instanceof SkinViewSupport) {
SkinView skinView = new SkinView(view, skinPairList);
//如果已经加载过换肤了,此时需要主动调用一次换肤方法
skinView.applySkin();
mSkinViewList.add(skinView);
}
}
//提供页面换肤功能
public void applySkin() {
for (SkinView skinView : mSkinViewList) {
skinView.applySkin();
}
}
//对应每一个换肤控件
static class SkinView {
View view;//换肤控件
List<SkinPair> skinPairList;//换肤属性集合
SkinView(View view, List<SkinPair> skinPairList) {
this.view = view;
this.skinPairList = skinPairList;
}
//关键方法:换肤方法(提供给Sdk自带控件)
public void applySkin() {
applySkinSupport();
/*
* 关键思路:1.获取原始App中resId对应的类型、名称
* 2.根据类型、名称、插件皮肤包名获取插件皮肤包中对应的resId
* 3.获取插件插件皮肤包中resId对应的资源(如:颜色、背景、图片)再设置给原始App中的控件实现换肤功能
*/
for (SkinPair skinPair : skinPairList) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
//后续的换肤属性可在此处添加
case "background":
Object background = SkinResources.getInstance().getBackground(skinPair
.resId);
//背景可能是 @color 也可能是 @drawable
if (background instanceof Integer) {
view.setBackgroundColor((int) 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;
default:
break;
}
if (null != left || null != right || null != top || null != bottom) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
bottom);
}
}
}
//提供给自定义控件进行换肤
public void applySkinSupport() {
if (view instanceof SkinViewSupport) {
((SkinViewSupport) view).applySkin();
}
}
}
//对应每一个换肤属性
static class SkinPair {
//换肤属性
String attributeName;
//资源Id
int resId;
SkinPair(String attributeName, int resId) {
this.attributeName = attributeName;
this.resId = resId;
}
}
}
这里要注意如果是自定义View需要实现SkinViewSupport接口,自己实现换肤功能,代码如下:
public interface SkinViewSupport {
void applySkin();
}
/**
* 注意:如果自定义View需要自己实现换肤,先通过属性获取ResourceId,再通过代码方式实现换肤
*/
public class MyTabLayout extends TabLayout implements SkinViewSupport {
int mTabIndicatorColorResId;
public MyTabLayout(@NonNull Context context) {
this(context, null);
}
public MyTabLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MyTabLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabLayout);
mTabIndicatorColorResId = a.getResourceId(R.styleable.TabLayout_tabIndicatorColor, 0);
a.recycle();
}
@Override
public void applySkin() {
if (mTabIndicatorColorResId != 0) {
int tabIndicatorColor = SkinResources.getInstance().getColor(mTabIndicatorColorResId);
setSelectedTabIndicatorColor(tabIndicatorColor);
}
}
}
由源码可知SkinLayoutInflateFactory必须在setContentView之前设置才能生效,这里有两种实现方式:
- 封装BaseActivity中,但侵入性比较强
- 在ActivityLifecycleCallbacks的onActivityCreated方法中添加,AOP思想(推荐)
核心代码如下:
public class ApplicationActivityLifecycle implements Application.ActivityLifecycleCallbacks {
private Observable mObservable;
private ArrayMap<Activity, SkinLayoutInflateFactory> mSkinLayoutInflateFactory = new ArrayMap<>();
public ApplicationActivityLifecycle(Observable observable) {
this.mObservable = observable;
}
@Override
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
//Activity -->LayoutInflate -->SkinLayoutInflateFactory
//为每一个Activity对应的LayoutInflate添加SkinLayoutInflateFactory
LayoutInflater layoutInflater = activity.getLayoutInflater();
try {
//注意:LayoutInflate的setFactory2方法中将mFactorySet设置成true了,第二次调用会报错,所以此处使用反射手动修改成false
Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
field.setAccessible(true);
field.setBoolean(layoutInflater, false);
} catch (Exception e) {
e.printStackTrace();
}
SkinLayoutInflateFactory factory = new SkinLayoutInflateFactory(activity);
LayoutInflaterCompat.setFactory2(layoutInflater, factory);
//添加换肤观察者
mObservable.addObserver(factory);
mSkinLayoutInflateFactory.put(activity, factory);
}
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
SkinLayoutInflateFactory factory = mSkinLayoutInflateFactory.get(activity);
mObservable.deleteObserver(factory);
}
}
加载插件皮肤包
通过创建AssetManager加载插件皮肤包,核心代码如下:
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager,skinPath);
替换资源实现换肤效果
替换资源的流程:通过原始App的resId获取对应的名称、类型,再根据名称、类型、插件包名去皮肤包中查找出对应的resId,获取插件resId对应的资源再设置给原始App的控件,从而实现换肤。
资源替换工具类:
public class SkinResources {
//插件App包名
private String mSkinPgk;
//是否使用默认皮肤包
private boolean mDefaultSkin = true;
//原始App的资源
private Resources mAppResources;
//插件App的资源
private Resources mSkinResources;
private SkinResources(Context context) {
mAppResources = context.getResources();
}
private volatile static SkinResources instance;
public static void init(Context context) {
if (instance == null) {
synchronized (SkinResources.class) {
if (instance == null) {
instance = new SkinResources(context);
}
}
}
}
public static SkinResources getInstance() {
return instance;
}
//设置皮肤包资源
public void applySkin(Resources skinResources, String skinPgk) {
mSkinResources = skinResources;
mSkinPgk = skinPgk;
mDefaultSkin = skinResources == null || TextUtils.isEmpty(skinPgk);
}
//恢复默认皮肤包
public void reset() {
mSkinResources = null;
mDefaultSkin = true;
mSkinPgk = "";
}
/**
* 1.通过原始app中的resId(R.color.XX)获取到自己的名字和类型
* 2.根据名字和类型获取皮肤包中的resId
*/
public int getIdentifier(int resId) {
if (mDefaultSkin) return resId;
String name = mAppResources.getResourceEntryName(resId);
String type = mAppResources.getResourceTypeName(resId);
return mSkinResources.getIdentifier(name, type, mSkinPgk);
}
public int getColor(int resId) {
if (mDefaultSkin) return mAppResources.getColor(resId);
int skinId = getIdentifier(resId);
if (skinId == 0) return mAppResources.getColor(resId);
return mSkinResources.getColor(skinId);
}
public ColorStateList getColorStateList(int resId) {
if (mDefaultSkin) return mAppResources.getColorStateList(resId);
int skinId = getIdentifier(resId);
if (skinId == 0) return mAppResources.getColorStateList(resId);
return mSkinResources.getColorStateList(skinId);
}
public Drawable getDrawable(int resId) {
if (mDefaultSkin) return mAppResources.getDrawable(resId);
//通过 app的resource 获取id 对应的 资源名 与 资源类型
//找到 皮肤包 匹配 的 资源名资源类型 的 皮肤包的 资源 ID
int skinId = getIdentifier(resId);
if (skinId == 0) return mAppResources.getDrawable(resId);
return mSkinResources.getDrawable(skinId);
}
/**
* 背景可能是Color 也可能是drawable
*/
public Object getBackground(int resId) {
String resourceTypeName = mAppResources.getResourceTypeName(resId);
if ("color".equals(resourceTypeName)) {
return getColor(resId);
} else {
return getDrawable(resId);
}
}
}
换肤管理类,负责App换肤功能:
public class SkinManager extends Observable {
private Application mContext;
private volatile static SkinManager instance;
public static void init(Application application) {
if (instance == null) {
synchronized (SkinManager.class) {
if (instance == null) {
instance = new SkinManager(application);
}
}
}
}
private SkinManager(Application application) {
mContext = application;
application.registerActivityLifecycleCallbacks(new ApplicationActivityLifecycle(this));
SkinResources.init(application);
SkinPreference.init(application);
//加载上次使用保存的皮肤
loadSkin(SkinPreference.getInstance().getSkin());
}
public static SkinManager getInstance() {
return instance;
}
//加载换肤插件
public void loadSkin(String skinPath) {
if (TextUtils.isEmpty(skinPath)) {
SkinPreference.getInstance().reset();
SkinResources.getInstance().reset();
} else {
try {
Resources appResources = mContext.getResources();
//创建AssetManager对象用于加载换肤插件
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager,skinPath);
//创建Resources用于加载换肤插件的资源
Resources skinResources = new Resources(assetManager, appResources.getDisplayMetrics(), appResources.getConfiguration());
//根据皮肤插件路径获取加载换肤插件的包名
PackageManager packageManager = mContext.getPackageManager();
PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES);
String packageName = packageArchiveInfo.packageName;
//设置皮肤
SkinResources.getInstance().applySkin(skinResources, packageName);
//记录当前皮肤
SkinPreference.getInstance().setSkin(skinPath);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 关键要点:
* 上面设置完皮肤后,要通知页面进行换肤,此处采用观察者模式进行通知,通知的对象为SkinLayoutInflateFactory,
* SkinLayoutInflateFactor在调用SkinAttribute的applySkin方法进行换肤
*/
setChanged();
notifyObservers();
}
}
这里采用了观察者模式通知多页面换肤,SkinManager对应Observable,SkinLayoutInflateFactory对应Observer,当SkinManager调用loadSkin进行换肤后,会通知SkinLayoutInflateFactory回调update方法,而SkinLayoutInflateFactory包含了SkinAttribute,在update方法中调用SkinAttribute的applySkin方法便可以通知到页面控件进行资源替换,从而实现换肤效果。
制作插件皮肤包
皮肤包只需要包含资源文件并且资源的名称要与原始App保持一致,制作完成后上传到服务的,客户端按需下载皮肤包,进行加载以及换肤操作
完整代码实现
百度链接
密码:wmay