概述
本文主要分享類似于酷狗音樂動態(tài)換膚效果的實現(xiàn)。
動態(tài)換膚的思路:
- 收集換膚控件以及對應的換膚屬性
- 加載插件皮膚包
- 替換資源實現(xiàn)換膚效果
- 制作插件皮膚包
收集換膚控件以及對應的換膚屬性
換膚實際上進行資源替換,如替換字體、顏色、背景、圖片等,對應控件屬性有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;
}
通過源碼可知創(chuàng)建控件會先調(diào)用Factory2的onCreateView方法,如果返回的View為空才會調(diào)用LayoutInflater中的onCreateView與createView,那我們自定一個Factory2就可以用于創(chuàng)建控件并判斷是否包含換膚屬性了。核心代碼如下:
public class SkinLayoutInflateFactory implements LayoutInflater.Factory2, Observer {
static final String mPrefix[] = {
"android.view.",
"android.widget.",
"android.webkit.",
"android.app."
};
//xml中控件的初始化都是調(diào)用帶Context和AttributeSet這個構(gòu)造方法進行反射創(chuàng)建的
static final Class<?>[] mConstructorSignature = new Class[]{
Context.class, AttributeSet.class};
//減少相同控件反射的次數(shù)
private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
new HashMap<>();
//記錄每一個頁面需要換膚的控件
private SkinAttribute mSkinAttribute;
/*
* 關(guān)系: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);
}
//關(guān)鍵代碼:采集需要換膚的控件
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的主要工作是:
- 創(chuàng)建xml中的控件
- 收集需要換膚的控件
創(chuàng)建控件主要是參考系統(tǒng)源碼實現(xiàn)的,重點在于收集換膚控件,通過SkinAttribute記錄每一個頁面需要換膚的控件,核心代碼如下:
public class SkinAttribute {
//需要換膚的屬性集合,如背景、顏色、字體等
private static final List<String> mAttributes = new ArrayList<>();
static {
//后續(xù)的換膚屬性可在此處添加
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;
//判斷是否使用系統(tǒng)資源
if (attributeValue.startsWith("?")) {// ? 系統(tǒng)資源
int attrId = Integer.parseInt(attributeValue.substring(1));
//獲取獲得Theme中屬性中定義的資源id
resId = SkinThemeUtils.getThemeResId(view.getContext(), new int[]{attrId})[0];
} else {//@ 開發(fā)者自定義資源
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);
//如果已經(jīng)加載過換膚了,此時需要主動調(diào)用一次換膚方法
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;
}
//關(guān)鍵方法:換膚方法(提供給Sdk自帶控件)
public void applySkin() {
applySkinSupport();
/*
* 關(guān)鍵思路:1.獲取原始App中resId對應的類型、名稱
* 2.根據(jù)類型、名稱、插件皮膚包名獲取插件皮膚包中對應的resId
* 3.獲取插件插件皮膚包中resId對應的資源(如:顏色、背景、圖片)再設置給原始App中的控件實現(xiàn)換膚功能
*/
for (SkinPair skinPair : skinPairList) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
//后續(xù)的換膚屬性可在此處添加
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需要實現(xiàn)SkinViewSupport接口,自己實現(xiàn)換膚功能,代碼如下:
public interface SkinViewSupport {
void applySkin();
}
/**
* 注意:如果自定義View需要自己實現(xiàn)換膚,先通過屬性獲取ResourceId,再通過代碼方式實現(xiàn)換膚
*/
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之前設置才能生效,這里有兩種實現(xiàn)方式:
- 封裝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了,第二次調(diào)用會報錯,所以此處使用反射手動修改成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);
}
}
加載插件皮膚包
通過創(chuàng)建AssetManager加載插件皮膚包,核心代碼如下:
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager,skinPath);
替換資源實現(xiàn)換膚效果
替換資源的流程:通過原始App的resId獲取對應的名稱、類型,再根據(jù)名稱、類型、插件包名去皮膚包中查找出對應的resId,獲取插件resId對應的資源再設置給原始App的控件,從而實現(xiàn)換膚。
資源替換工具類:
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.根據(jù)名字和類型獲取皮膚包中的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();
//創(chuàng)建AssetManager對象用于加載換膚插件
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager,skinPath);
//創(chuàng)建Resources用于加載換膚插件的資源
Resources skinResources = new Resources(assetManager, appResources.getDisplayMetrics(), appResources.getConfiguration());
//根據(jù)皮膚插件路徑獲取加載換膚插件的包名
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();
}
}
/**
* 關(guān)鍵要點:
* 上面設置完皮膚后,要通知頁面進行換膚,此處采用觀察者模式進行通知,通知的對象為SkinLayoutInflateFactory,
* SkinLayoutInflateFactor在調(diào)用SkinAttribute的applySkin方法進行換膚
*/
setChanged();
notifyObservers();
}
}
這里采用了觀察者模式通知多頁面換膚,SkinManager對應Observable,SkinLayoutInflateFactory對應Observer,當SkinManager調(diào)用loadSkin進行換膚后,會通知SkinLayoutInflateFactory回調(diào)update方法,而SkinLayoutInflateFactory包含了SkinAttribute,在update方法中調(diào)用SkinAttribute的applySkin方法便可以通知到頁面控件進行資源替換,從而實現(xiàn)換膚效果。
制作插件皮膚包
皮膚包只需要包含資源文件并且資源的名稱要與原始App保持一致,制作完成后上傳到服務的,客戶端按需下載皮膚包,進行加載以及換膚操作
完整代碼實現(xiàn)
百度鏈接
密碼:wmay