Android 插件換膚的原理和源碼分析

在學習安卓插件化開發的路上,有一處風景是肯定要觀賞的,那就是基于插件的應用換膚了。

插件換膚原理概述

基于 插件進行應用換膚 的技術大致可以分為兩個方面:

  • 如何加載插件包中各式各樣的資源,如 drawable、color 等。
  • 如何定位到需要換膚的控件,并優雅地更改樣式,如 無須重啟換膚 等。

針對第一個問題,相關的研究已經比較多了,通過研究 Resource 類 的源碼,在其構造函數中有個 AssetManager 類參數,而最終獲取資源都是通過 AssetManager 來獲取的。

于是,通過構造 AssetManager 并生成插件的 Resource 類,就可以加載插件包中的資源。

針對第二個問題,首先是定位需要換膚的控件,大多數是通過在控件的 XML 布局中添加 標識 ,標識那些需要換膚的控件及需要改變的屬性。然后再通過控件的 set 方法改變屬性即可。

在改變控件的屬性時,若每次都通過遍歷頁面所有 View 來換膚則性能開銷太大,通過 LayoutInflater.Factory 接口在加載布局文件時便先處理所有 View 的屬性,只保存那些需要換膚的控件,則會優化性能。

當然還是有其他問題待解決的,例如: Resource 類加載的資源 ID 沖突, 插件 Resource 不同安卓版本的兼容性,使用 LayoutInflater.Factory 是一種侵入式編程,會干涉系統構造 View 的過程,如何無侵入的換膚,動態加載的控件如何進行換膚 等等問題……

看到很多換膚的框架都參考了該工程,也來分析一下其原理。

再了解插件換膚的大致原理后,再去分析換膚框架的源碼就變得簡單多了,無非就是要解決上述的問題,下面就對 Android-Skin-Loader 源碼進行分析。

動態加載插件資源

在 SkinManager 的 load 方法中,加載了插件包,并且得到了插件的資源 Resource 。

PackageManager mPm = context.getPackageManager();
                        PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
                        // 得到插件包名,根據包名和資源 ID 得到資源
                        skinPackageName = mInfo.packageName;

                        // 通過反射構造 AssetManager 類
                        AssetManager assetManager = AssetManager.class.newInstance();
                        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
                        // 反射調用 addAssetPath 方法
                        addAssetPath.invoke(assetManager, skinPkgPath);
                        // 得到皮膚插件的 Resource
                        Resources superRes = context.getResources();
                        Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());

                        // 保存皮膚包路徑
                        SkinConfig.saveSkinPath(context, skinPkgPath);

有了插件資源 Resource ,就可以去得到想要的資源了。

換膚控件及屬性的標識

Android-Skin-Loader 框架自定義了一個 enable 的屬性,用在 XML 文件中來標識哪些控件需要進行換膚。

并且 Android-Skin-Loader 在需要繼承的基類 BaseActivity 、 BaseFragment 、 BaseFragmentActivity 中都設置了 LayoutInflater.Factory ,以便在布局加載之前進行預操作,也就是保存那些 需要換膚的控件 和識別 需要換膚的屬性,這里 換膚控件換膚屬性 兩個東西要區別開,它們所要進行的操作是不一樣的,要先找到 換膚控件 ,然后再去找它的 換膚屬性

@Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        // 從 AttributeSet 中得到換膚屬性,判斷是否需要進行換膚
        boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
        if (!isSkinEnable){
                return null; // 不需要換膚的,則返回 null,由系統構造
        }
        // 構造需要換膚的 View
        View view = createView(context, name, attrs);
        if (view == null){
            return null;
        }
        // 解析需要換膚 View 的屬性
        parseSkinAttr(context, attrs, view);
        return view;
    }

以上代碼就是我們所說的侵入式編程,干擾了系統構造 View 的過程,所做的工作就是找出需要換膚的 View 并交由下一步進行解析。

private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
        List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();

        for (int i = 0; i < attrs.getAttributeCount(); i++){
            String attrName = attrs.getAttributeName(i);
            String attrValue = attrs.getAttributeValue(i);

            if(!AttrFactory.isSupportedAttr(attrName)){
                continue; // AttrFactory 定義了哪些屬性支持換膚,若該屬性不支持換膚就跳過繼續
            }
            if(attrValue.startsWith("@")){ // 表明是引用類型,例如 @color/red
                    int id = Integer.parseInt(attrValue.substring(1));
                    String typeName = context.getResources().getResourceTypeName(id); // 類型名
                    String entryName = context.getResources().getResourceEntryName(id); // 入口名
                    SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
                    if (mSkinAttr != null) {
                        viewAttrs.add(mSkinAttr);
                    }
            }
            }
        if(!ListUtils.isEmpty(viewAttrs)){
            SkinItem skinItem = new SkinItem();
            skinItem.view = view;
            skinItem.attrs = viewAttrs;
            // 將需要 換膚的控件 和 換膚的屬性 這兩個東西進行保存
            mSkinItems.add(skinItem);
            if(SkinManager.getInstance().isExternalSkin()){
                skinItem.apply(); // 如果是外部的皮膚,則要 apply 一下,防止換膚不及時
            }
        }
    }

上述代碼的作用就是從需要換膚的控件中,找到那些需要更改的屬性,并將它保存在 mSkinItems 全局變量中。

Android-Skin-Loader 框架中有一個抽象基類 SkinAttr 表示需要更改的屬性,而具體需要更改的屬性都是繼承自 SkinAttr ,所以如果想要更改更多的屬性,就必須自己添加對應的 SkinAttr 類了。

解析完屬性后,換膚控件及屬性都被保存在了 mSkinItems 全局變量中,這樣就完成了加載布局界面的預操作。

顯然,Android-Skin-Loader 框架對于解決找出待更改的屬性這一問題,并不是那么的方便,并且干預了系統構造 View 的過程。

下面研究 hongyang 大神的解決思路: AndroidChangeSkin 代碼。

AndroidChangeSkin 框架并沒有使用 LayoutInflater.Factory 方案了,采用了一種無侵入的方案。

對于標識需要換膚的控件這一問題,AndroidChangeSkin 并沒有再添加自定義屬性,而是使用 View 自帶的 tag 屬性。并在在 tag 屬性的字符串值中,傳遞了要換膚的標識、要換膚的屬性、要換膚的屬性名。通過解析這三者來完成標識的任務。這樣就不必要對每個屬性都進行操作了。

//傳入activity,找到content元素,遞歸遍歷所有的子View,根據tag命名,記錄需要換膚的View
    public static List<SkinView> getSkinViews(Activity activity)
    {
        List<SkinView> skinViews = new ArrayList<SkinView>();
        ViewGroup content = (ViewGroup) activity.findViewById(android.R.id.content);
        addSkinViews(content, skinViews); // 找到需要換膚的 View 放到 skinViews 里面
        return skinViews;
    }

     /**
     * 得到換膚的 View ,如果 tag 為 null 或者 tag 不是字符串,則返回 null
     * 解析需要換膚的 View ,得到所有需要更改的屬性 SkinAttr
     */
    public static SkinView getSkinView(View view)
    {
        Object tag = view.getTag(R.id.skin_tag_id);
        if (tag == null)
        {
            tag = view.getTag();
        }
        if (tag == null) return null;
        if (!(tag instanceof String)) return null;
        String tagStr = (String) tag;

        List<SkinAttr> skinAttrs = parseTag(tagStr);
        if (!skinAttrs.isEmpty())
        {
            changeViewTag(view);
            return new SkinView(view, skinAttrs);
        }
        return null;
    }

AndroidChangeSkin 完成查找 換膚控件換膚屬性 兩大任務,之所以說是無侵入性,就是因為它是從 Activity 布局的頂層開始遍歷的,是在布局文件加載完成之后。

換膚操作及響應回調

完成了 View 的標識及查找任務之后,剩下就是最終的換膚操作了。

要做到無須重啟應用和 Activity 完成換膚,Android-Skin-Loader 和 AndroidChangeSkin 都是基于 觀察者模式 來處理的,也就是通過回調方法。

收到進行換膚的指令時,在頁面中響應回調方法,通過皮膚插件的 Resource 加載對應的資源完成替換。

在此之前,我們找到了需要換膚的 View 和需要更改的屬性,那么最終的換膚操作也就是由這些 View 來設置它的新屬性,插件資源的加載也就是發生在這里了。也只有在這個時候,才會去加載皮膚插件中的資源,而之前的第一步只是構造插件的 Resource 并沒有加載資源。

Android-Skin-Loader 是為每一個需要更改的屬性定義了一個類,并在此類中去加載資源。

public class TextColorAttr extends SkinAttr {

    @Override
    public void apply(View view) {
        if(view instanceof TextView){
            TextView tv = (TextView)view;
            if(RES_TYPE_NAME_COLOR.equals(attrValueTypeName)){
         tv.setTextColor(SkinManager.getInstance().convertToColorStateList(attrValueRefId));

而 androidChangeSkin 則沒有編寫那么多類,采用了枚舉類型來更改屬性,同樣也是在屬性中加載資源。

public enum SkinAttrType
{
    BACKGROUND("background"){
                @Override
                public void apply(View view, String resName)
                {
                    Drawable drawable = getResourceManager().getDrawableByName(resName);
                    if (drawable != null)
                    {
                        view.setBackgroundDrawable(drawable);
                    } else{
                    try{
                         int color = getResourceManager().getColor(resName);
                         view.setBackgroundColor(color);
                    },

至于,基于觀察者模式來響應換膚操作就比較簡單了,看過代碼很容易知道是怎么一個編程方式了。

動態添加 View 的換膚

以上只是分析了從布局文件中加載的換膚,對于運行時動態添加的 View 同樣可以換膚,只不過不能再 XML 文件中添加屬性了。

畢竟即使是在運行時添加的 View 也是要先確定好需求,編寫對應代碼的。只不過少了標識該 View 是否需要換膚這一步,直接找到需要換膚的屬性就好了,在收到換膚指令時,也是加載插件資源,直接更改屬性即可。

總結

在總結了基于插件換膚的原理和相關代碼之后,發現其實應用換膚也不是那么難嘛….

參考

1、http://blog.zhaiyifan.cn/2015/09/10/Android%E6%8D%A2%E8%82%A4%E6%8A%80%E6%9C%AF%E6%80%BB%E7%BB%93/

2、http://blog.csdn.net/zhi184816/article/details/53436761

3、http://www.jianshu.com/p/af7c0585dd5b

 

來自:http://www.glumes.com/android-change-skin-by-plugin/

 

 本文由用戶 BirgitI87 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!