前言:
換膚油挥,目前包括靜態(tài)換膚和動態(tài)換膚
靜態(tài)換膚
這種換膚的方式,也就是我們所說的內(nèi)置換膚,就是在APP內(nèi)部放置多套相同的資源。進行資源的切換款熬。
這種換膚的方式有很多缺點深寥,比如, 靈活性差贤牛,只能更換內(nèi)置的資源惋鹅、apk體積太大,在我們的應用Apk中等一般圖片文件能占到apk大小的一半左右。
當然了,這種方式也并不是一無是處, 比如我們的應用內(nèi)盔夜,只是普通的 日夜間模式 的切換负饲,并不需要圖片等的更換堤魁,只是更換顏色,那這樣的方式就很實用。
動態(tài)換膚
適用于大量皮膚返十,用戶選擇下載妥泉,像QQ、網(wǎng)易云音樂這種洞坑。它是將皮膚包下載到本地盲链,皮膚包其實是個APK。
換膚包括替換圖片資源迟杂、布局顏色刽沾、字體、文字顏色排拷、狀態(tài)欄和導航欄顏色侧漓。
動態(tài)換膚步驟包括:
1、采集需要換膚的控件
2监氢、 加載皮膚包
3布蔗、 替換資源
鏈接:http://www.reibang.com/p/eebb8eae5ea1
按照步驟我們試著實現(xiàn)一下動態(tài)換膚的效果
1、采集需要的換膚控件浪腐,比如(android.widget.TextView纵揍,android.widget.ImageView)
通過采集支持換膚的控件以及屬性,然后保存到集合中议街,待遍歷替換
那么怎么采集控件呢泽谨?
我們可以看下setContentView(int id)這個指定布局的方法
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);//這里實現(xiàn)view布局的加載
mOriginalWindowCallback.onContentChanged();
}
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
return inflate(resource, root, root != null);
}
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
if (DEBUG) {
Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
+ Integer.toHexString(resource) + ")");
}
final XmlResourceParser parser = res.getLayout(resource);
try {
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
...
final String name = parser.getName();
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
...
return temp;
}
可以看到inflate會返回具體的View對象出去,那么我們的關注焦點就放在createViewFromTag中了
/**
* Creates a view from a tag name using the supplied attribute set.
* <p>
* <strong>Note:</strong> Default visibility so the BridgeInflater can
* override it.
*
* @param parent the parent view, used to inflate layout params
* @param name the name of the XML tag used to define the view
* @param context the inflation context for the view, typically the
* {@code parent} or base layout inflater context
* @param attrs the attribute set for the XML tag used to define the view
* @param ignoreThemeAttr {@code true} to ignore the {@code android:theme}
* attribute (if set) for the view being inflated,
* {@code false} otherwise
*/
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
try {
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;
}
return view;
} catch (Exception e) {
}
}
為了方便特漩,這里代碼直接轉(zhuǎn)至 http://www.reibang.com/p/eebb8eae5ea1
inflate最終調(diào)用了createViewFromTag方法來創(chuàng)建View,在這之中用到了factory吧雹,如果factory存在就用factory創(chuàng)建對象,如果不存在就由系統(tǒng)自己去創(chuàng)建拾稳。我們只需要實現(xiàn)我們的Factory然后設置給mFactory2就可以采集到所有的View了吮炕。
到目前為止我們只知道要去自定義一個factory,那么這個東西到底是什么呢? 上面我們通過源碼簡單的了解了访得,如果factory存在就用factory創(chuàng)建對象龙亲,如果不存在就由系統(tǒng)自己去創(chuàng)建view。
那么我們就重寫一個factory
public class SkinLayoutInflateFactory implements LayoutInflater.Factory2 {
private static final String TAG = SkinLayoutInflateFactory.class.getSimpleName();
private Activity activity;
public SkinLayoutInflateFactory(Activity mActivity) {
this.activity = mActivity;
}
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
//do sth
return null;
}
@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
return null;
}
}
可以看到方法onCreateView是創(chuàng)建view的方法悍抑,其中AttributeSet表示屬性集鳄炉。為了方便管理以及盡可能減少代碼的入侵,我們使用ActivityLifecycleCallbacks搜骡。
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
private static final String TAG = SkinActivityLifecycle.class.getSimpleName();
private Map<Activity,SkinLayoutInflateFactory> factoryMap = new HashMap<>();
@Override
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
LayoutInflater layoutInflater = LayoutInflater.from(activity);
try {
//Android 布局加載器 使用 mFactorySet 標記是否設置過Factory
//如設置過拋出一次
//設置 mFactorySet 標簽為false
Field mFactorySet = LayoutInflater.class.getDeclaredField("mFactorySet");
mFactorySet.setAccessible(true);
mFactorySet.setBoolean(layoutInflater,false);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
Log.d(TAG, "onActivityCreated: ");
//使用factory2 設置布局加載工程
SkinLayoutInflateFactory skinLayoutInflaterFactory = new SkinLayoutInflateFactory(activity);
LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutInflaterFactory);
factoryMap.put(activity,skinLayoutInflaterFactory);
}
@Override
public void onActivityStarted(@NonNull Activity activity) {
}
@Override
public void onActivityResumed(@NonNull Activity activity) {
}
@Override
public void onActivityPaused(@NonNull Activity activity) {
}
@Override
public void onActivityStopped(@NonNull Activity activity) {
}
@Override
public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
}
@Override
public void onActivityDestroyed(@NonNull Activity activity) {
factoryMap.remove(activity);
}
}
現(xiàn)在演示下SkinLayoutInflateFactory的使用拂盯,我們在SkinLayoutInflateFactory的onCreateView打印AttributeSet屬性
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
Log.d(TAG, "onCreateView: name "+ name);
int attributeCount = attrs.getAttributeCount();
for (int i = 0; i < attributeCount; i++) {
Log.d(TAG, "onCreateView: "+attrs.getAttributeName(i)+"---"+attrs.getAttributeValue(i));
}
return null;
}
name androidx.constraintlayout.widget.ConstraintLayout
layout_width----1
layout_height----1
name TextView
layout_width----2
layout_height----2
text---Hello World!
layout_constraintBottom_toBottomOf---0
layout_constraintLeft_toLeftOf---0
layout_constraintRight_toRightOf---0
layout_constraintTop_toTopOf---0
對應的布局
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
可以看到布局和屬性是一一對應的,那么現(xiàn)在我們玩一個好玩的東西记靡,把textview換成button
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context, @NonNull AttributeSet attrs) {
switch (name){
case "TextView":
Button button = new Button(context);
button.setText("替換文本");
button.setTextColor(Color.RED);
return button;
}
return null;
}
通過以上的實驗我們就可以簡單的理解谈竿,自定義factory就可以獲取到需要換膚的控件了团驱,但是控件還包含了自定義控件,比如com.xxx.widget.MyView, 或者Android系統(tǒng)的控件空凸,剩下的就是只有標簽的比如ImageView的控件嚎花,但是只帶標簽的控件需要補全包名,android.widget.ImageView.才能轉(zhuǎn)成View
通過以上分析我們先完成控件篩選
private static final String[] mClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
//獲取只帶標簽的控件
View view = createViewFromTag(name, context, attrs);
//如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
if (null == view) {
view = createView(name, context, attrs);
}
if (null != view) {
L.e(String.format("檢查[%s]:" + name, context.getClass().getName()));
}
return view;
}
private View createViewFromTag(String name, Context context, AttributeSet
attrs) {
//如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
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) {
L.e(String.format("name= [%s]:",name));
Constructor<? extends View> constructor = findConstructor(context, name);
try {
return constructor.newInstance(context, attrs);
} catch (Exception e) {
}
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);
L.e(String.format("constructor name = [%s]",constructor.getName()));
mConstructorMap.put(name, constructor);
} catch (Exception e) {
}
}
return constructor;
}
代碼分析
如果name包含有 " . "則說明可能是自定義控件(比如com.wzw.MyView)或者系統(tǒng)控件(android.support.v4.view.ViewPager)否則需要添加完整包名呀洲。出現(xiàn)異常比如循環(huán)中可能出現(xiàn)
android.view.TextView則拋出異常不處理紊选,最后通過反射的原理轉(zhuǎn)換成view。
//獲取只帶標簽的控件道逗,添加包名轉(zhuǎn)成view
View view = createViewFromTag(name, context, attrs);
//如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
if (null == view) {
view = createView(name, context, attrs);
}
return view;
2兵罢、加載皮膚包
實際上皮膚包也是一個Android文件,不管你將后綴名改為什么滓窍,只要是創(chuàng)建出的Android 項目就會存在res包以及底下的文件卖词,利用這個特點,我們可以將想要替換的資源文件放到對應的包中贰您,那么換膚的時候就只要去加載皮膚包中的對應的圖片就可以了坏平。
好了問題來了,比如我們需要替換某個ImageView的圖片锦亦,那么正常的做法是要先加載到皮膚包中的資源文件,context.getResource.getDrawable(xxxx)令境;那么請問杠园,如果我們這么寫能加載到資源嗎?
答案是否定的舔庶,因為context是屬于當前app的上下文抛蚁,并不能加載插件app的資源文件。那么要怎么獲取資源并設置呢惕橙?
我們先來看下Resource的構造方法:
@Deprecated
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
this(null);
mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
}
先看后面兩個參數(shù)分別表示屏幕相關參數(shù)和設備信息瞧甩。這兩個參數(shù)可以使用本app的context提供,重點看AssetManager
AssetsManager 直接對接Android系統(tǒng)底層弥鹦。
Assets Manager有一個方法:addAssetPath(String path) 方法肚逸,app啟動的時候會把當前的APK路徑傳遞進去,然后我們就可以訪問資源了彬坏。
根據(jù)思路我們將插件apk放在app項目的assets文件夾下朦促,然后寫入緩存文件中提供訪問。接著我們必須創(chuàng)建出resource和assetmanger栓始,以及dexclassloader(可訪問未安裝apk類)
//獲取assetManager
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager,dataFile.getAbsolutePath());
//獲取到插件resource
Resources appResource = application.getResources();
Resources skinResource = new Resources(assetManager,appResource.getDisplayMetrics(),appResource.getConfiguration());
//獲取插件進程名
PackageManager packageManager = application.getPackageManager();
PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(dataFile.getAbsolutePath(), PackageManager.GET_ACTIVITIES);
String packageName = packageArchiveInfo.applicationInfo.packageName;
//獲取插件dexClassloader
File optimizedDirectory = application.getDir("dex", Context.MODE_PRIVATE);
DexClassLoader dexClassLoader = new DexClassLoader(dataFile.getAbsolutePath(),optimizedDirectory.getAbsolutePath(),
null,application.getClassLoader());
SkinResource.getInstance().init(dexClassLoader,skinResource,appResource,packageName);
3务冕、替換資源
第一步我們以及篩選除了需要替換的控件以及控件的屬性,為了簡單說明幻赚,目前我們制作Imageview的background的替換以及Textview的文本顏色替換禀忆。
public void setDrawable(ImageView imageView, String drawableName) {
try {
Class<?> aClass = dexClassLoader.loadClass(skinPackageName + ".R$mipmap");
Field field = aClass.getField(drawableName);
//獲取到圖片的id
int anInt = field.getInt(R.id.class);
imageView.setImageDrawable(skinResources.getDrawable(anInt));
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
public void setTextColor(TextView textView, String colorName) {
try {
Class<?> aClass = dexClassLoader.loadClass(skinPackageName + ".R$color");
Field field = aClass.getField(colorName);
Log.d(TAG, "setTextColor: "+field.getName());
int anInt = field.getInt(R.id.class);
textView.setTextColor(skinResources.getColor(anInt));
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
代碼說明:
通過dexClassloader后去mipmap和color資源文件臊旭,其中兩個方法中的drawableName和colorName分別表示資源名,比如mipmap中保存了一張 ic_bg.png箩退,那么drawableName 就等于“ ic_bg”。
接著通過field獲取同名資源文件的資源id,最后通過插件的resource對象設置乏德,達到替換的效果。
喊括。
那么現(xiàn)在的重點就是怎么獲取drawableName(colorName)。
在第一步的時候我們收集了xml中的控件以及屬性
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
//獲取只帶標簽的控件
View view = createViewFromTag(name, context, attrs);
//如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
if (null == view) {
view = createView(name, context, attrs);
}
if (view != null) {
attribute.load(view, attrs);
}
return view;
}
我們繼續(xù)看 attribute.load(view, attrs);方法
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);
if (mAttributes.contains(attributeName)) {
//獲取屬性對應的值
String attributeValue = attrs.getAttributeValue(i);
if (attributeValue.startsWith("#")) {
continue;
}
int resId = 0;
//判斷前綴字符串 是否是"?"
//attributeValue = "?2130903043"
if (attributeValue.startsWith("?")) { //系統(tǒng)屬性值
} else {
//@1234564
resId = Integer.parseInt(attributeValue.substring(1));
}
if (resId != 0) {
SkinPain skinPain = new SkinPain(attributeName, resId, view.getClass().getName());
skinPains.add(skinPain);
}
}
}
if (!skinPains.isEmpty() || view instanceof TextView) {
SkinView skinView = new SkinView(view, skinPains);
// skinView.applySkin();
skinViews.add(skinView);
}
}
其實以上代碼還是通過attrs這個類獲取到控件名字郑什,控件的屬性府喳,以及通過attrs.getAttributeValue(int i);獲取到了設置的資源名字蘑拯,比如?6453213432,通過字符串截取,就可以獲取到當前設置的resId申窘,緊接利用resId,通過Resources.getResourceEntryName(resId)方法我們就可以獲取到resName了弯蚜。
public String getResName(int resId) {
//R.drawable.ic_launcher
return appResources.getResourceEntryName(resId);//ic_launcher /colorPrimaryDark
}
由于換膚的前提是宿主設置的資源名和插件的資源名一致,所以通過獲取到宿主設置的資源名我們就可以獲取到插件的資源名從而設置進去剃法。
本例子我們使用了Observable觀察者,當點擊按鈕加載資源的時候就通知被觀察設置插件中的同名資源從而達到了換膚的效果收厨。
總結
該例子只做了ImageView背景替換和TextView文本顏色替換优构,當然還有類似自定義控件的替換,文本字體替換等钦椭,這里就不做一一解釋。因為我們只要懂得核心就可以舉一反三玉凯。總體的步驟就是:
1捎拯、采集需要換膚的控件
2、 加載皮膚包
3署照、 替換資源
他的核心還是離不開Android的插件化
Android插件化從技術上來說就是如何啟動未安裝的apk(主要是四大組件)里面的類,主要問題涉及如何加載類没隘、如何加載資源禁荸、如何管理組件生命周期。
感興趣的可以參考 https://zhuanlan.zhihu.com/p/136001039