外掛化實現Android多主題功能原理剖析

Anderson大碼渣發表於2019-02-26

前言

之前我們總結過B站的皮膚框架MagicaSakura,也點出了其不足,文章連結:來自B站的開源的MagicaSakura原始碼解析,該框架只能完成普通的換色需求,沒有QQ,網易雲音樂類似的皮膚包的功能。

那麼今天我們就帶來,擁有皮膚載入功能的外掛化換膚框架。框架的分裝和使用具體可以看我的工程裡面的程式碼。
github.com/Jerey-Jobs/…

這樣做有兩個好處:

  1. 皮膚可以不整合在apk中,減小apk體積
  2. 動態化增加皮膚,靈活性大,自由度很大

如何實現換膚功能

想當然的,在View建立的時候這是讓我們應用能夠完美的載入皮膚的最好方案。

那麼我們知道,對於Activity來說,有一個可以複寫的方法叫onCreateView

@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
    return super.onCreateView(parent, name, context, attrs);
}複製程式碼

我們的view的建立就是通過這個方法來的,我們甚至可以通過複寫這個方法,實現view的替換,比如本來要的是TextView,我們直接給它替換成Button.而這個方法其實是實現的LayoutInflaterFactory介面。

關於LayoutInflaterFactory,我們可以看一下鴻神的文章www.tuicool.com/articles/EV…

建立View

根據拿到的onCreateView裡面的name,來反射建立View,這邊用到了一個技巧:onCreateView中的name,對於系統的View,是沒有'.'符號的,比如"TextView"我們拿到的直接是TextView,
但是自定義的View,我們拿到的是帶有包名的全部名稱,因此反射時,對於系統的View,我們需要加上系統的包名,自定義的View,則直接使用name。

也不用疑問為什麼用反射,這樣不是慢嗎?

因為系統的LayoutInflater在createView的時候也是這麼做的,這邊的程式碼都是參考系統的實現的。

    private static final String[] sClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };
    static View createViewFromTag(Context context, String name, AttributeSet attrs) {
        if (name.equals("view")) {
            name = attrs.getAttributeValue(null, "class");
        }

        try {
            mConstructorArgs[0] = context;
            mConstructorArgs[1] = attrs;
            // 系統控制元件,沒有".",因此去建立系統View
            if (-1 == name.indexOf('.')) {
                // 根據名稱反射建立
                for (int i = 0; i < sClassPrefixList.length; i++) {
                    final View view = createView(context, name, sClassPrefixList[i]);
                    if (view != null) {
                        return view;
                    }
                }
                return null;
                // 有'.'的情況下是自定義View,V4與V7也會走
            } else {
                // 直接根據名稱建立View
                return createView(context, name, null);
            }
        } catch (Exception e) {
            // We do not want to catch these, lets return null and let the actual LayoutInflater
            // try
            return null;
        } finally {
            // Don't retain references on context.
            mConstructorArgs[0] = null;
            mConstructorArgs[1] = null;
        }
    }

    /**
 * 反射,使用View的兩引數構造方法建立View
 * @param context
 * @param name
 * @param prefix
 * @return
 * @throws ClassNotFoundException
 * @throws InflateException
 */
private static View createView(Context context, String name, String prefix)
        throws ClassNotFoundException, InflateException {
    Constructor<? extends View> constructor = sConstructorMap.get(name);

    try {
        if (constructor == null) {
            // Class not found in the cache, see if it's real, and try to add it
            Class<? extends View> clazz = context.getClassLoader().loadClass(
                    prefix != null ? (prefix + name) : name).asSubclass(View.class);

            constructor = clazz.getConstructor(sConstructorSignature);
            sConstructorMap.put(name, constructor);
        }
        constructor.setAccessible(true);
        return constructor.newInstance(mConstructorArgs);
    } catch (Exception e) {
        // We do not want to catch these, lets return null and let the actual LayoutInflater
        // try
        return null;
    }
}複製程式碼

判斷View是否需要換膚

與建立View一樣,根據拿到的onCreateView裡面的AttributeSet attrs

拿到後,我們解析attrs

/**
 * 拿到attrName和value
 * 拿到的value是R.id
 */
String attrName = attrs.getAttributeName(i);//屬性名
String attrValue = attrs.getAttributeValue(i);//屬性值複製程式碼

根據屬性名和屬性值進行判斷,有背景的屬性,是否符合需要換膚的屬性、

外掛化資源注入

我們的皮膚包其實是APK,是我們寫的另一個app,與正式App不同的是,其只有資原始檔,且資原始檔需要和主app同名。

1.通過 PackageManager拿皮膚包名
2.拿到皮膚包裡面的Resource

但是因為我們想new Resources()時候,發現其第一個引數是AssetManager,但是AssetManager的構造方法在原始碼中被@hide了,我們沒有方法拿到這個類,但是幸好其類還是能拿到的,我們直接反射獲取。

我們拿資源的程式碼如下。

PackageManager mPm = context.getPackageManager();
PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
skinPackageName = mInfo.packageName;
/**
 * AssetManager assetManager = new AssetManager();
 * 這個方法被@ hide了。。我們只能通過反射newInstance
 */
AssetManager assetManager = AssetManager.class.newInstance();
/**
 * addAssetPath同樣被系統給hide了
 */
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);
Resources superRes = context.getResources();
Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
/**
 * 講皮膚路徑儲存,並設定不是預設皮膚
 */
SkinConfig.saveSkinPath(context, params[0]);
skinPath = skinPkgPath;
isDefaultSkin = false;
/**
 * 到此,我們拿到了外接皮膚包的資源
 */
return skinResource;複製程式碼

如何動態的從皮膚包中獲取資源

我們以從皮膚包裡面獲取color來舉例

業務端是通過資源的id來獲取color的,資源的id也就是一個在編譯時就生成的int型。 而皮膚包的也是編譯時生成的,因此兩個id是不一樣的,我們只能通過資源的id先拿到在我們應用裡的該id的名字,再通過名字去資源包裡面拿資源。

public int getColor(int resId) {
    int originColor = ContextCompat.getColor(context, resId);
    /**
     * 如果皮膚資源包不存在,直接載入
     */
    if (mResources == null || isDefaultSkin) {
        return originColor;
    }
    /**
     * 每個皮膚包裡面的id是不一樣的,只能通過名字來拿,id值是不一樣的。
     * 1. 獲取預設資源的名稱
     * 2. 根據名稱從全域性mResources裡面獲取值
     * 3. 若獲取到了,則獲取顏色返回,若獲取不到,老老實實使用原來的
     */
    String resName = context.getResources().getResourceEntryName(resId);

    int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
    int trueColor;
    if (trueResId == 0) {
        trueColor = originColor;
    } else {
        trueColor = mResources.getColor(trueResId);
    }
    return trueColor;
}複製程式碼

實際使用

上面都是我們外掛化載入的需要了解的知識,真的進行框架使用的時候,使用了自定義屬性,根據自定義屬性判斷是否需要換膚。

使用觀察者模式,所有需要換膚的view都會存放在Activity一個集合中,在皮膚管理器通知皮膚更新時,主動更新檢視狀態。

說了這麼多了,框架的分裝和使用具體可以看我的工程裡面的程式碼。
github.com/Jerey-Jobs/…

效果如圖:

程式碼見:github.com/Jerey-Jobs/…

歡迎star

APK下載 App下載連結


本文作者:Anderson/Jerey_Jobs

部落格地址 : jerey.cn/

簡書地址 : Anderson大碼渣

github地址 : github.com/Jerey-Jobs

相關文章