前置知識(shí)
- 需要了解setContentView的具體流程
- 需要了解LayoutInflater的inflate過程
- 需要了解Resources資源文件是如何獲取的
原理
首先我們要先從AppCompatActivity 中的 setContentView開始追溯,因?yàn)槲覀冃枰繟ndroid是如何創(chuàng)建View的桑驱,只有這樣才能知道如何修改這個(gè)View的屬性。
AppCompatActivity setContentView
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}
可以看到這個(gè)調(diào)用了AppCompatDelegateImpl的setContentView,繼續(xù)往下看:
AppCompatDelegateImpl setContentView
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mAppCompatWindowCallback.getWrapped().onContentChanged();
}
這里我們需要重點(diǎn)看的是LayoutInflater.from(mContext).inflate(resId, contentParent)這句代碼,眾所周知扰魂,android.R.id.content實(shí)際上就是一個(gè)FrameLayout税迷,而我們平時(shí)調(diào)用的setContentView實(shí)際上就是被這個(gè)FrameLayout包裹著的,所以這里通過LayoutInfalter的inflate方法把我們傳入的layout布局文件加載到拿到的contentParent中翰绊。接下來來看一下這個(gè)inflate方法里面做了什么:
LayoutInflater inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot)
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) + ")");
}
View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
if (view != null) {
return view;
}
XmlResourceParser parser = res.getLayout(resource);
try {
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
可以看到這里創(chuàng)建了 XmlResourceParser XML 解析器,并調(diào)用inflate(parser, root, attachToRoot)方法,下面來看看這個(gè)方法监嗜。
LayoutInflater inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot)
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
// 此處省略多行代碼......
try {
if (TAG_MERGE.equals(name)) {
// 此處省略多行代碼......
} else {
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
// 此處省略多行代碼......
}
} catch (XmlPullParserException e) {
// 此處省略多行代碼......
} catch (Exception e) {
// 此處省略多行代碼......
} finally {
// 此處省略多行代碼......
}
return result;
}
}
LayoutInflater createViewFromTag
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
// 此處省略多行代碼......
View view = tryCreateView(parent, name, context, attrs);
try {
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
// 判斷是否是系統(tǒng)View
if (-1 == name.indexOf('.')) {
view = onCreateView(context, parent, name, attrs);
} else {
// 若非系統(tǒng)View谐檀,則通過構(gòu)造器反射生成
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch() {
}
// 此處省略多行代碼......
}
在這個(gè)方法里最重要的是 createViewFromTag 這個(gè)方法,這個(gè)方法使用來完成 View 的加載裁奇,這個(gè)會(huì)先通過 tryCreateView 來創(chuàng)建 View 桐猬,可以看到下面的代碼會(huì)先通過判斷 mFactory2 或者 mFactory 是否為null,如果不為null則會(huì)直接創(chuàng)建View框喳,否則View返回null课幕,然后在 createViewFromTag 中還會(huì)通過 name.indexOf('.') 來判斷該 View 是系統(tǒng) View 還是自定義 View ,如果是系統(tǒng) View 五垮,則直接給該 View 加上 “android.view.” 前綴乍惊,否則直接使用全包名來創(chuàng)建 View 實(shí)例。
LayoutInflater tryCreateView
@Nullable
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;
}
最后會(huì)調(diào)用 createView(context, name, null, attrs) 來創(chuàng)建 View 實(shí)例放仗,在這個(gè)方法中的 sConstructorMap 緩存了 View 的構(gòu)造方法润绎,如果已經(jīng)加載過,則直接從緩存使用構(gòu)造方法創(chuàng)建View的實(shí)例诞挨,否則使用 ClassLoader 反射得到 View 的構(gòu)造器莉撇。最后通過構(gòu)造器使用反射,調(diào)用了 View 的兩個(gè)構(gòu)造方法反射完成 View 的創(chuàng)建惶傻,將創(chuàng)建完的 View 執(zhí)行 addView 將視圖添加到到 DecorView 中棍郎。這部分代碼這里就不貼出來了,大家可以自己在AS里面點(diǎn)進(jìn)去看看银室。
通過上面的代碼可以看出涂佃,如果我們想要改變一個(gè)View的屬性,我們可以通過創(chuàng)建一個(gè)Factory2
來攔截View的加載蜈敢,并在這個(gè)加載過程中改變View的屬性辜荠。
如何·獲取資源文件
首先我們來手動(dòng)搜索一下Resources類中的getColor方法
Resources
@ColorInt
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
final TypedValue value = obtainTempTypedValue();
try {
final ResourcesImpl impl = mResourcesImpl;
impl.getValue(id, value, true);
if (value.type >= TypedValue.TYPE_FIRST_INT
&& value.type <= TypedValue.TYPE_LAST_INT) {
return value.data;
} else if (value.type != TypedValue.TYPE_STRING) {
throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)
+ " type #0x" + Integer.toHexString(value.type) + " is not valid");
}
final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
return csl.getDefaultColor();
} finally {
releaseTempTypedValue(value);
}
}
可以看到我們平時(shí)在獲取資源文件時(shí)都做了哪些操作,這里重點(diǎn)看ResourcesImpl的getValue方法抓狭,下面貼出該方法的代碼:
ResourcesImpl
void getValue(@AnyRes int id, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
boolean found = mAssets.getResourceValue(id, 0, outValue, resolveRefs);
if (found) {
return;
}
throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id));
}
而上面的 mAssets 是 AssetManager 伯病,也就是說我們最后是通過 AssetManager 的 getResourceValue 方法來拿到資源文件的》窆看懂了上面的原理的話午笛,就能來實(shí)操了,但是這里不提供實(shí)操代碼苗桂,只提供思路药磺。
如何實(shí)現(xiàn)插件化換膚
-
首先要實(shí)現(xiàn)插件化換膚的話首先在項(xiàng)目中定義資源文件(如color、drawable)等的時(shí)候命名要規(guī)范誉察,比如color不能直接使用#ffffff這種形式与涡,一定要在value目錄下創(chuàng)建相對(duì)應(yīng)的資源惹谐,同時(shí)要在插件包上定義一模一樣的名字持偏。示例如下:
app module下的values colors.xml
<color name="title_bar_bg">#ff2244</color> <color name="title_bar_text_color">#ffffff</color> <color name="button_primary">#2D3136</color> <color name="button_secondary">#41474D</color>
插件包skinpkg下的values colors.xml
<color name="TextPrimary">#000000</color> <color name="TextSecondary">#90959A</color> <color name="TextTertiary">#60646A</color> <color name="title_bar_bg_skin">#FFC53D</color>
當(dāng)資源文件都處理完畢后驼卖,可以把插件打包打成apk,打完包后可以把該apk的后綴改名鸿秆,我一般是改成xxx.skin的酌畜,目的是為了當(dāng)該插件包是保存在本地的時(shí)候,避免用戶把它當(dāng)成apk安裝(如果獲取插件包的形式是通過網(wǎng)路下載卿叽,則可忽略此點(diǎn))桥胞。打包之后先把該包放在本地路徑中進(jìn)行測(cè)試。
-
然后就是實(shí)現(xiàn)換膚的最重要步驟啦考婴,上面我們已經(jīng)知道了我們是通過 AssetManager 來獲取資源文件的贩虾,所以我們首先需要通過反射來創(chuàng)建一個(gè)插件包的AssetManager:
val assetManager = AssetManager::class.java.newInstance() val addAssetPath = assetManager.javaClass.getMethod("addAssetPath", String::class.java) addAssetPath.invoke(assetManager, skinPath) // skinPath為插件包保存在本地的文件路徑
然后通過反射得到的assetManager來創(chuàng)建插件包的Resources,這里需要提一下的是:大家平時(shí)在獲取color或者drawable的時(shí)候都是通過Resources.getXxx()來獲取的沥阱,所以這里要?jiǎng)?chuàng)建插件包的Resources:
val appResource = mContext.resources // 根據(jù)當(dāng)前的設(shè)備顯示器信息 與配置(橫豎屏缎罢、語言等)創(chuàng)建Resources val skinResource = Resources(assetManager, appResource.displayMetrics, appResource.configuration)
這里我們先來說一下我們是如何改變View的屬性值的(如background、src考杉、textColor策精、drawableStart、drawableTop崇棠、drawableEnd咽袜、drawableBottom),每個(gè)View都會(huì)有AttributeSet枕稀,而在AttributeSet中會(huì)記錄著該View中的所有屬性询刹,我們需要遍歷這些屬性看看是否有我們需要修改的屬性值,然后拿到這些屬性的resId抽莱》蹲ィ回到一開始的問題,如何改變屬性值呢食铐?當(dāng)我們拿到resId的時(shí)候匕垫,我們可以通過resId來拿到該resId對(duì)應(yīng)的resName,然后再通過該resName去拿到插件包中的對(duì)應(yīng)的resId虐呻,最后再通過上面創(chuàng)建的skinResource來獲取對(duì)應(yīng)的資源就好了象泵。
下面來一段代碼講解一下,不然可能都看懵了:
private val mAttributes = arrayOf(
"background",
"src",
"textColor",
"drawableLeft",
"drawableTop",
"drawableRight",
"drawableBottom",
)
fun getViewAttrs(view: View, attrs: AttributeSet) {
for (i in 0 until attrs.attributeCount) {
val attributeName = attrs.getAttributeName(i)
if (mAttributes.contains(attributeName)) {
val attributeValue = attrs.getAttributeValue(i)
if (attributeValue.startsWith("#")) { // 如果是以 # 開頭的則跳過斟叼,因?yàn)椴环弦?guī)范
continue
}
// 以 ?attr 開頭的
val resId = if (attributeValue.startsWith("?")) {
val attrId = attributeValue.substring(1).toInt()
// 這里是獲取 ?attr 資源id的寫法偶惠,在這里不會(huì)把這段代碼貼出來,想要了解的可以自行搜索
SkinThemeUtil.getResId(view.context, intArrayOf(attrId))[0]
} else {
// 以 @ 開頭的
attributeValue.substring(1).toInt()
}
}
}
}
通過以上的代碼獲取resId后朗涩,就可以獲取得到該resId對(duì)應(yīng)的resName了(注意這里拿到的都是app內(nèi)的資源id忽孽,以下稱為宿主app),接下來就可以拿到skinResource中對(duì)應(yīng)的resId了,看下面的代碼:
private val mAppResources by lazy { applicationContext.resources }
/**
* 通過原始app中的resId獲取resName
* 然后通過resName與resType獲取皮膚包中的resId
*/
private fun getIdentifier(resId: Int) : Int {
val resName = mAppResources.getResourceEntryName(resId)
val resType = mAppResources.getResourceTypeName(resId)
// 這里的mSkinPkgName是插件包的包名
// 可以通過packageManager.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES)?.packageName來獲取
return mSkinResources?.getIdentifier(resName, resType, mSkinPkgName) ?: 0
}
/**
* 通過上述方法拿到skinResId兄一,然后就可以通過mSkinResources?.getColor(skinResId)去修改View的屬性了
*/
fun getColor(resId: Int): Int {
val skinResId = getIdentifier(resId)
return if (skinResId == 0) mAppResources.getColor(resId)
else mSkinResources?.getColor(skinResId) ?: 0
}
/**
* @return 可能是Color 也可能是drawable
*/
fun getBackground(resId: Int): Any? {
val resourceTypeName = mAppResources.getResourceTypeName(resId)
// 當(dāng)修改background的時(shí)候要注意屬性是color還是drawable厘线,這個(gè)相信大家都知道~
return if ("color" == resourceTypeName) {
getColor(resId)
} else {
// 此方法大家可自行實(shí)現(xiàn),這里就不貼出來了
getDrawable(resId)
}
}
好了出革,我們的寫完這些資源文件的獲取邏輯之后就可以自定義LayoutInflater.Factory2
來重寫onCreateView
然后加入我們的邏輯啦:
// 此處為偽代碼
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
// 創(chuàng)建系統(tǒng)View造壮,這里需要跟最上面講的inflate過程中的通過name.indexOf('.')來判斷是否是系統(tǒng)View對(duì)應(yīng),可自行了解
var view: View? = createSDKView(name, context, attrs)
if (null == view) {
// 如果不是系統(tǒng)View骂束,則通過全包名創(chuàng)建自定義View耳璧,創(chuàng)建的過程與上面講的createView(context, name, null, attrs)一致,通過ClassLoader反射得到View的構(gòu)造器展箱,然后通過構(gòu)造器使用反射旨枯,調(diào)用了View的構(gòu)造方法完成View的創(chuàng)建
view = createView(name, context, attrs)
}
//這就是我們加入的邏輯
if (null != view) {
//加載屬性
}
return view
}
未解決的問題
由于Androidn P 更新了非SDK接口的限制,導(dǎo)致我們需要重設(shè)的LayoutInflate中的mFactorySet字段無法被反射重設(shè)混驰,所以如果要自己封裝一個(gè)插件換膚框架的話暫時(shí)還沒有解決方法(Android 10之前不受影響)召廷,而上面所說的自定義Factory2并重寫onCreateView的方法,目前只適用于在每個(gè)需要更改的Activity中的onCreate中的super方法前調(diào)用账胧,這樣才能攔截View的加載竞慢。
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
private boolean mFactorySet;
可以看到這里的mFactorySet被限制了,而為什么需要反射修改這個(gè)字段呢治泥,可以看下面的代碼:
public void setFactory2(Factory2 factory) {
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
if (factory == null) {
throw new NullPointerException("Given factory can not be null");
}
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}
如果不反射修改這個(gè)字段的話筹煮,就會(huì)拋出A factory has already been set on this LayoutInflater異常,無法重新設(shè)置一個(gè)新的LayoutInflater居夹,而如果按照我上述說的在Activity中的onCreate中的super方法前調(diào)用败潦,這樣就不會(huì)受該字段的影響,因?yàn)樵贏ctivity的onCreate方法中的super方法准脂,已經(jīng)間接的調(diào)用了setFactory2劫扒,上面的代碼也可以看到,當(dāng)調(diào)用了這個(gè)方法后 mFactorySet = true 狸膏,所以下次要想重設(shè)LayoutInflater的話沟饥,就會(huì)拋異常。
總結(jié)
這個(gè)通過反射修改mFactorySet的插件換膚目前只有Android 10之前才有用湾戳,而如果Android 10之后想要寫插件換膚的可以嘗試一下ASM字節(jié)碼插樁
贤旷,與上面所講的思路是一樣的,不過我目前還未研究砾脑,等研究出來了再貼出代碼幼驶。