Android插件化換膚(僅限Android P以前可使用)

前置知識(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é)碼插樁贤旷,與上面所講的思路是一樣的,不過我目前還未研究砾脑,等研究出來了再貼出代碼幼驶。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市韧衣,隨后出現(xiàn)的幾起案子盅藻,更是在濱河造成了極大的恐慌购桑,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,204評(píng)論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件氏淑,死亡現(xiàn)場離奇詭異其兴,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)夸政,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,091評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來榴徐,“玉大人守问,你說我怎么就攤上這事】幼剩” “怎么了耗帕?”我有些...
    開封第一講書人閱讀 164,548評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長袱贮。 經(jīng)常有香客問我仿便,道長,這世上最難降的妖魔是什么攒巍? 我笑而不...
    開封第一講書人閱讀 58,657評(píng)論 1 293
  • 正文 為了忘掉前任嗽仪,我火速辦了婚禮,結(jié)果婚禮上柒莉,老公的妹妹穿的比我還像新娘闻坚。我一直安慰自己,他們只是感情好兢孝,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,689評(píng)論 6 392
  • 文/花漫 我一把揭開白布窿凤。 她就那樣靜靜地躺著,像睡著了一般跨蟹。 火紅的嫁衣襯著肌膚如雪雳殊。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,554評(píng)論 1 305
  • 那天窗轩,我揣著相機(jī)與錄音夯秃,去河邊找鬼。 笑死痢艺,一個(gè)胖子當(dāng)著我的面吹牛寝并,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播腹备,決...
    沈念sama閱讀 40,302評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼衬潦,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼!你這毒婦竟也來了植酥?” 一聲冷哼從身側(cè)響起镀岛,我...
    開封第一講書人閱讀 39,216評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤弦牡,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后漂羊,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體驾锰,經(jīng)...
    沈念sama閱讀 45,661評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,851評(píng)論 3 336
  • 正文 我和宋清朗相戀三年走越,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了椭豫。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,977評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡旨指,死狀恐怖赏酥,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情谆构,我是刑警寧澤裸扶,帶...
    沈念sama閱讀 35,697評(píng)論 5 347
  • 正文 年R本政府宣布,位于F島的核電站搬素,受9級(jí)特大地震影響呵晨,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜熬尺,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,306評(píng)論 3 330
  • 文/蒙蒙 一摸屠、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧粱哼,春花似錦餐塘、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,898評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽尝哆。三九已至识虚,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間蛤织,已是汗流浹背艺挪。 一陣腳步聲響...
    開封第一講書人閱讀 33,019評(píng)論 1 270
  • 我被黑心中介騙來泰國打工不翩, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人麻裳。 一個(gè)月前我還...
    沈念sama閱讀 48,138評(píng)論 3 370
  • 正文 我出身青樓口蝠,卻偏偏與公主長得像,于是被迫代替她去往敵國和親津坑。 傳聞我的和親對(duì)象是個(gè)殘疾皇子妙蔗,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,927評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容