1. 程式人生 > >android方便簡單的零侵入可擴充套件的換膚框架

android方便簡單的零侵入可擴充套件的換膚框架

    目前的外掛化正如火如荼,外掛化開源的也不少,比如360開源的Replugin,滴滴的VirtualApk等等,當然我們今天的主題並不是外掛化,而是外掛化換膚;

    android的換膚功能的實現基本有兩種,一種是應用內換膚,一種是外掛化換膚,應用內換膚比較簡單,基本都是在內部預置幾套面板,但是這樣的話,一兩套的面板來說還好,如果更多的話,會造成apk的體積非常大,很不好,如果我們能夠動態的通過網路下載面板,動態的去更換,豈不是更好;外掛化換膚,就能夠滿足這種使用場景,下面開始詳細實現該方案;

   想要外掛話換膚,我們可以通過網路下載只有資源的apk資源包,雖然資源有了,但是我們有以下幾個問題需要解決:

1,如何去獲取外掛資源?

2,如何找到需要資源的view?

3,我們該通過view的什麼方法去設定資源?

對於第一個問題,由於是外掛中的資源,外掛中的資源id在宿主的R檔案中是不存在的,我們該怎麼找到對應外掛中的資源呢。

其實這就需要我們定義一套規則了,我們可以規定外掛資源名字必須和宿主中的資源名字一致,有了這個規則,那麼我們可以根據資源的名字進行對應了:  宿主id —— 資源名字 —— 外掛id

我們通過宿主的資源id值找到對應的資源名字:

getResources().getResourceEntryName(resId);

我們通過該方法就可以拿到資源的名字,這一步完成了通過宿主id找資源名字的過程

getResources().getResourceTypeName(resId);

通過該方法我們可以拿到資源的型別,是color還是drawable等等;

現在我們需要構造外掛的Resources物件了,也很簡單,通過Assetmanager的addAssetpath方法,不過需要反射,不懂該操作的可以自行百度原理,我就不詳細深入分析了,直接上程式碼:

public class PluginResProvider {

    public static String getPkg(Context context,String skinApkPath){
        PackageInfo packageInfo = context.getPackageManager().getPackageArchiveInfo(skinApkPath, PackageManager.GET_UNINSTALLED_PACKAGES);
        return packageInfo !=null? packageInfo.packageName : null;
    }

    public static Resources getResources(Context context,String skinApkPath){
        AssetManager assetManager =getAssetManager(skinApkPath);
        if (assetManager !=null) {
            Resources hostRes = context.getResources();
            return new Resources(assetManager,hostRes.getDisplayMetrics(),hostRes.getConfiguration());
        }else {
            return null;
        }
    }

    public static AssetManager getAssetManager(String skinApkPath){
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager, skinApkPath);
            return assetManager;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

應該已經一目瞭然了吧,現在我們就可以通過資源名字拿到對應的外掛資源的id了,通過外掛的Resources的getIdentifier方法我們就拿到了外掛中的資源id,這樣我們就也可以在外掛的Resources物件中通過外掛的id來查詢資源了。

    接下來就是我們需要知道哪些view需要換膚的功能,當然這裡分為兩個方向,一個是對layout檔案入手,一個是從程式碼層面入手,如果從layout入手,我們需要自己去給view定義一個換膚的標識屬性,然後在給layoutInflater設定factory,在解析的時候去收集那些具有換膚tag的view,這種方法優點就是支援了在xml中的配置,減少了開發者程式碼層面的操作,缺點就是侵入性比較大,不光需要開發者在佈局檔案中去配置換膚屬性,也需要進行註冊,並且無法支援自定義屬性的一些換膚操作,比如一個自定義view有個setTitleColor方法,顯然這種方式擴充套件性比較差,靈活度也不高。另一種方式就是通過程式碼的方式,由開發者自己通過程式碼去設定哪個view需要換膚,需要什麼方法換膚等等,憂點就是靈活度高,擴充套件性強,幾乎零侵入,只需要開發者遵守資源名字一致即可,不需要再佈局檔案再去學習新的換膚標籤屬性,註冊解註冊很靈活,擴充套件性強,缺點就是需要程式碼量會多一些。

    基於以上,所以我選擇通過程式碼去實現換膚功能,為了能夠支援自定義view的屬性方法,能夠擴充套件,所以我認為封裝的時候不應該將換膚的具體方法寫死,而是應該提供一個介面讓開發者自己去實現,當然我們可以提供常用的方法就好,比如,setBackgorund,setBackgroundColor,setImageDrawable,setTextColor等等。還有就是,雖然本文的重點是換膚,但是我覺得我們不該侷限於此,如果我想把外掛中只要有id的資源都拿來用可不可以,比如,我們想用外掛中的anim,string,array等等,我們不應該侷限於換膚,它應該是一個可以拿到任何有id的資源,所以,我們也可以提供一個介面,讓開發者有能力去擴充套件他想要的任何外掛中的資源,我們只需要提供預設的即可,比如獲取color,drawable等等;


這是我封裝的包結構圖,首先fetchers包下是資源抓取器,主要是實現了一些預設獲取圖片和顏色的資源抓取,如果想擴充套件可以直接繼承ResFetcher類進行擴充套件:

public class ColorResFetcher extends ResFetcher<Integer> {

    @Override
    protected Integer getRes(Resources pluginRes, int pluginId) {
        return pluginRes.getColor(pluginId);
    }

    @Override
    public String name() {
        return FetcherType.COLOR;
    }

}

這個是顏色的抓取,由於資源的型別不是隨意的,所以,所有的資源型別我都預先定義在FetcherType類中:

@Retention(RetentionPolicy.SOURCE)
@StringDef({FetcherType.COLOR, FetcherType.DRAWABLE, FetcherType.STRING, FetcherType.ARRAY, FetcherType.ANIM, FetcherType.DIMEN, FetcherType.LAYOUT})
public @interface FetcherType {

    String COLOR = "color", DRAWABLE = "drawable", STRING = "string", 
            ARRAY = "array", ANIM = "anim", DIMEN = "dimen", LAYOUT = "layout";
}

這裡包含了很多資源型別名稱,如果想要擴充套件,可以直接仿照ColorResFetcher去實現自己想要的資源。

public abstract class ResFetcher<T> {

    public T getRes(ResProxy resProxy, ViewAttr attr) {
        T pluginRes = null;
        int pluginId = resProxy.getCacheId(attr.hostResId);
        if (pluginId == 0) {
            pluginId = resProxy.getPulginId(attr);
        }
        try {
            pluginRes = getRes(resProxy.getPluginRes(), pluginId);
        } catch (Exception e) {
        }
        if (pluginRes != null) {
            resProxy.saveId(attr.hostResId, pluginId);
        } else {

        }
        return pluginRes;
    }

    protected abstract T getRes(Resources pluginRes, int pluginId);

    public abstract @FetcherType
    String name();
}

如果想要註冊自己的資源抓取器,可以通過SkinCenter.get().addFetcher方法,下面看看SkinCenter這個類:

public class SkinCenter {

    private static final String TAG = SkinCenter.class.getSimpleName();
    private static SkinCenter skinCenter;
    private WeakHashMap<View, SparseArray<ViewAttr>> cacheMap = new WeakHashMap<>();
    private ResProxy mResProxy;

    private SkinCenter() {
        addDefaultFetchers();
        addDefaultConverts();
    }

    @MainThread
    public static SkinCenter get() {
        if (skinCenter == null) {
            skinCenter = new SkinCenter();
        }
        return skinCenter;
    }

    public void init(Context context) {
        mResProxy = new ResProxy(context.getApplicationContext());
        loadPlugin(context);
    }

    /**
     * 新增預設的資源解析器
     */
    private void addDefaultFetchers() {
        addFetcher(new ColorResFetcher()).addFetcher(new DrawableResFetcher());
    }

    public SkinCenter addFetcher(ResFetcher fetcher) {
        ResFetcherM.addFetcher(fetcher);
        return this;
    }

    /**
     * 新增預設的方法轉換器
     */
    private void addDefaultConverts() {
        addConvert(new SetBackground())
                .addConvert(new SetBackgroundColor())
                .addConvert(new SetTextColor())
                .addConvert(new SetImageDrawable());
    }

    public SkinCenter addConvert(IViewConvert convert) {
        ConvertM.addConvert(convert);
        return this;
    }

    /**
     * 載入外掛資源
     * @param context
     */
    private void loadPlugin(Context context) {
        String skinPath = SkinCache.getCurSkinPath(context);
        if (TextUtils.isEmpty(skinPath)){
            loadDefaultSkin(context);
        }else {
            loadSkin(context,skinPath);
        }
    }

    /**載入宿主預設的資源
     * @param context
     */
    public void loadDefaultSkin(Context context){
        SkinCache.saveSkinPath(context,"");
        if (mResProxy ==null){
            mResProxy =new ResProxy(context.getApplicationContext());
        }
        mResProxy.setPlugin(context.getResources(),context.getPackageName());
        notifySkinChanged();
    }

    /**提供外部呼叫載入外掛資源
     * @param context
     * @param skinPath
     */
    public void loadSkin(Context context,String skinPath){
        if (TextUtils.isEmpty(skinPath) || !new File(skinPath).exists()){
            return;
        }
        String pkg = PluginResProvider.getPkg(context, skinPath);
        Resources pluginRes =PluginResProvider.getResources(context,skinPath);
        if (pkg ==null || pluginRes ==null){
            return;
        }
        SkinCache.saveSkinPath(context,skinPath);
        if (mResProxy ==null){
            mResProxy =new ResProxy(context.getApplicationContext());
        }
        mResProxy.setPlugin(pluginRes,pkg);
        notifySkinChanged();
    }

    public <V extends View> void apply(V view, int resId, String convertName) {
        apply(view, resId, ConvertM.getConvert(convertName));
    }

    public <V extends View> void apply(V view, int resId, IViewConvert convert) {
        if (mResProxy == null) {
            init(view.getContext());
        }
        applyAndRegist(view, resId, convert);
    }

    private <T,V extends View> void applyAndRegist(V view, int resId, IViewConvert convert) {
        SparseArray<ViewAttr> attrs = cacheMap.get(view);
        if (attrs != null) {
            ViewAttr viewAttr = attrs.get(resId);
            if (viewAttr != null) {
                viewAttr.apply(view, mResProxy);
            }
        } else {
            attrs = new SparseArray<>();
            String idName = mResProxy.getResourceEntryName(resId);
            String idType = mResProxy.getResourceTypeName(resId);
            ResFetcher<T> fetcher = ResFetcherM.getFetcher(idType);
            if (idName != null && idType != null && fetcher != null) {
                ViewAttr attr = new ViewAttr<T,V>(resId, idName, idType, convert, fetcher);
                attrs.put(resId, attr);
                cacheMap.put(view, attrs);
                attr.apply(view, mResProxy);
            } else {
                Log.e(TAG, "no match res,idName=" + idName + " ,idType=" + idType + " ,fetcher=" + fetcher);
            }
        }
    }


    private void notifySkinChanged() {
        Iterator it = cacheMap.entrySet().iterator();
        while (it.hasNext()) {
            Map.Entry entry = (Map.Entry) it.next();
            View view = (View) entry.getKey();
            SparseArray<ViewAttr> attrs = (SparseArray<ViewAttr>) entry.getValue();
            for (int i = 0; i < attrs.size(); i++) {
                attrs.valueAt(i).apply(view, mResProxy);
            }
        }
    }
}
這個類時對外提供的一個核心類,主要負責換膚view的註冊,資源抓取器的註冊,初始化等等,我們無需關心該怎麼初始化,比如:
SkinCenter.get().apply(iv_image, R.color.colorAccent, SetBackgroundColor.class.getSimpleName());

一句程式碼就已經完成了換膚功能,iv_image是你要換膚的view,第二個引數是資源id,最後是方法轉換器的類名,這個是預設已經寫好的,在上面的類結構圖裡面已經列舉了,如果SetBackgroundColor不能滿足你,你可以自己實現IVewConvert介面,這個相當於方法轉換器:

public class SetBackgroundColor implements IViewConvert<Integer, View> {


    @Override
    public void apply(View view, Integer res) {
        view.setBackgroundColor(res);
    }
}

這個就是預設提供的,你可以自己去定義自己的方法邏輯;

當然如果你想要換膚,有了apk的路徑,那麼你只需要這樣做:

SkinCenter.get().loadSkin(context,skinPath);

一句話即可換膚,所有需要的換膚的view都會自動換膚,是不是很簡單。

有不足之處,歡迎大家提出,下面是原始碼地址: